diff --git a/.github/workflows/flutter_build.yml b/.github/workflows/flutter_build.yml index be7b41f55..fc909ebf2 100644 --- a/.github/workflows/flutter_build.yml +++ b/.github/workflows/flutter_build.yml @@ -30,7 +30,7 @@ jobs: - run: "flutter upgrade" - run: "flutter --version" - run: "flutter pub get" - - run: "flutter build ios --no-codesign" + - run: "flutter build ios -t 'lib/main_netknights.dart' --no-codesign" build_appbundle: name: (Android) @@ -41,7 +41,7 @@ jobs: # matrix job fails. fail-fast: false matrix: - api-level: [ 21,31 ] # [minSdk, most used, newest (30 is not working :(] 19 would be minSDK but does not support x86_64 + api-level: [ 21, 30, 34 ] # [minSdk, most used, newest] target: [ default ] # [default, google_apis] steps: - uses: actions/checkout@v2 @@ -56,5 +56,5 @@ jobs: - run: "flutter --version" - run: "flutter pub get" - run: 'flutter clean' - - run: "flutter build apk --debug" + - run: "flutter build apk -t 'lib/main_netknights.dart' --debug" diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml new file mode 100644 index 000000000..e1bd64ac5 --- /dev/null +++ b/.github/workflows/test-integration.yaml @@ -0,0 +1,35 @@ +name: Flutter integration test +on: + push: + branches: + - master + pull_request: +jobs: + drive_android: + runs-on: macos-latest + strategy: + matrix: + api-level: [29] + target: [playstore] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '11' + + - uses: subosito/flutter-action@v1 + with: + flutter-version: '3.13.2' + channel: 'stable' + + # Run integration test + - name: Run Flutter Driver tests + uses: reactivecircus/android-emulator-runner@v2 + with: + target: ${{ matrix.target }} + api-level: ${{ matrix.api-level }} + arch: x86_64 + profile: Nexus 6 + script: flutter test integration_test + diff --git a/.gitignore b/.gitignore index 4ff529b3b..c7c2122a5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ *.iws .idea/ -# Visual Studio Code related -.vscode/ - # Flutter/Dart/Pub related **/doc/api/ .dart_tool/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..ecf8ea1e5 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "pi-authenticator (release mode)", + "request": "launch", + "type": "dart", + "program": "lib/main_netknights.dart", + "flutterMode": "release" + }, + { + "name": "pi-authenticator (debug mode)", + "request": "launch", + "type": "dart", + "program": "lib/main_netknights.dart", + "flutterMode": "debug" + }, + { + "name": "pi-authenticator (profile mode)", + "request": "launch", + "type": "dart", + "program": "lib/main_netknights.dart", + "flutterMode": "profile" + }, + ] +} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index a3be6b826..3df1067df 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,6 @@ -include: package:flutter_lints/flutter.yaml \ No newline at end of file +include: package:flutter_lints/flutter.yaml + +linter: + rules: + unnecessary_string_escapes: false + unnecessary_string_interpolations: false \ No newline at end of file diff --git a/android/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 000000000..342e93f60 --- /dev/null +++ b/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments=--init-script C\:\\Users\\FRANKM~1\\AppData\\Local\\Temp\\d146c9752a26f79b52047fb6dc6ed385d064e120494f96f08ca63a317c41f94c.gradle --init-script C\:\\Users\\FRANKM~1\\AppData\\Local\\Temp\\52cde0cfcf3e28b8b7510e992210d9614505e0911af0c190bd590d7158574963.gradle +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home=C\:/Program Files/Java/jdk-17 +jvm.arguments= +offline.mode=false +override.workspace.settings=true +show.console.view=true +show.executions.view=true diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51d69a214..bbdac0cd8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,7 +21,7 @@ android:exported="true" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" - android:launchMode="singleTop" + android:launchMode="singleInstance" android:theme="@style/LaunchTheme" android:windowSoftInputMode="adjustResize"> diff --git a/coverage/lcov.info b/coverage/lcov.info new file mode 100644 index 000000000..29fa84cc5 --- /dev/null +++ b/coverage/lcov.info @@ -0,0 +1,3398 @@ +SF:lib\model\states\token_folder_state.dart +DA:11,19 +DA:13,2 +DA:14,4 +DA:15,6 +DA:16,2 +DA:21,2 +DA:22,4 +DA:23,4 +DA:24,10 +DA:25,4 +DA:26,2 +DA:29,2 +DA:32,2 +DA:33,4 +DA:34,10 +DA:35,2 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:44,0 +DA:46,0 +DA:48,0 +DA:49,0 +DA:51,0 +DA:52,0 +DA:54,10 +LF:27 +LH:17 +end_of_record +SF:lib\model\token_folder.dart +DA:18,6 +DA:26,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,0 +DA:43,1 +DA:44,4 +DA:46,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:52,1 +DA:53,1 +DA:54,2 +DA:57,0 +LF:18 +LH:12 +end_of_record +SF:lib\model\mixins\sortable_mixin.dart +DA:6,0 +DA:7,0 +DA:8,0 +DA:11,0 +DA:13,0 +LF:5 +LH:0 +end_of_record +SF:lib\model\token_folder.g.dart +DA:9,2 +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,1 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +LF:13 +LH:6 +end_of_record +SF:lib\model\states\token_state.dart +DA:11,4 +DA:12,4 +DA:14,2 +DA:16,2 +DA:17,10 +DA:20,1 +DA:21,2 +DA:22,1 +DA:23,1 +DA:26,1 +DA:27,2 +DA:28,1 +DA:29,1 +DA:32,2 +DA:33,4 +DA:34,10 +DA:35,2 +DA:38,1 +DA:39,2 +DA:40,7 +DA:41,1 +DA:45,2 +DA:46,4 +DA:47,10 +DA:48,4 +DA:49,2 +DA:51,2 +DA:53,2 +DA:58,2 +DA:59,4 +DA:60,4 +DA:61,10 +DA:62,4 +DA:63,2 +DA:66,2 +DA:68,2 +DA:71,0 +DA:72,0 +DA:73,0 +LF:39 +LH:36 +end_of_record +SF:lib\model\tokens\token.dart +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,1 +DA:36,1 +DA:37,5 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:44,6 +DA:70,1 +DA:72,4 +DA:75,0 +DA:76,0 +DA:78,1 +DA:80,10 +LF:21 +LH:8 +end_of_record +SF:lib\model\states\settings_state.dart +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:19,0 +DA:20,0 +DA:28,0 +DA:30,0 +DA:31,0 +DA:35,1 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:53,1 +DA:63,1 +DA:64,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:69,1 +DA:70,1 +DA:71,1 +DA:75,1 +DA:77,9 +DA:79,0 +DA:80,0 +DA:83,1 +DA:88,1 +DA:89,3 +DA:90,3 +DA:91,3 +DA:92,3 +DA:93,5 +DA:94,5 +DA:95,3 +DA:96,3 +DA:99,0 +DA:100,0 +DA:101,0 +LF:48 +LH:23 +end_of_record +SF:lib\l10n\app_localizations.dart +DA:68,0 +DA:72,0 +DA:73,0 +DA:852,16 +DA:854,0 +DA:856,0 +DA:859,0 +DA:860,0 +DA:862,0 +DA:866,0 +DA:870,0 +DA:871,0 +DA:872,0 +DA:873,0 +DA:874,0 +DA:875,0 +DA:876,0 +DA:877,0 +DA:880,0 +LF:19 +LH:1 +end_of_record +SF:lib\l10n\app_localizations_cs.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:141,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:133 +LH:0 +end_of_record +SF:lib\l10n\app_localizations_de.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:141,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:133 +LH:0 +end_of_record +SF:lib\l10n\app_localizations_en.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:141,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:133 +LH:0 +end_of_record +SF:lib\l10n\app_localizations_es.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:141,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:133 +LH:0 +end_of_record +SF:lib\l10n\app_localizations_fr.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:141,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:133 +LH:0 +end_of_record +SF:lib\l10n\app_localizations_nl.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:132 +LH:0 +end_of_record +SF:lib\l10n\app_localizations_pl.dart +DA:5,0 +DA:7,0 +DA:10,0 +DA:13,0 +DA:16,0 +DA:19,0 +DA:22,0 +DA:25,0 +DA:28,0 +DA:31,0 +DA:34,0 +DA:37,0 +DA:40,0 +DA:43,0 +DA:46,0 +DA:49,0 +DA:52,0 +DA:55,0 +DA:58,0 +DA:61,0 +DA:64,0 +DA:67,0 +DA:70,0 +DA:72,0 +DA:75,0 +DA:78,0 +DA:81,0 +DA:83,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:95,0 +DA:98,0 +DA:101,0 +DA:104,0 +DA:107,0 +DA:110,0 +DA:113,0 +DA:116,0 +DA:119,0 +DA:122,0 +DA:125,0 +DA:128,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:139,0 +DA:144,0 +DA:146,0 +DA:149,0 +DA:152,0 +DA:155,0 +DA:158,0 +DA:161,0 +DA:164,0 +DA:167,0 +DA:170,0 +DA:173,0 +DA:176,0 +DA:179,0 +DA:182,0 +DA:185,0 +DA:188,0 +DA:191,0 +DA:194,0 +DA:197,0 +DA:200,0 +DA:203,0 +DA:206,0 +DA:209,0 +DA:212,0 +DA:215,0 +DA:218,0 +DA:221,0 +DA:224,0 +DA:227,0 +DA:229,0 +DA:232,0 +DA:235,0 +DA:238,0 +DA:241,0 +DA:244,0 +DA:247,0 +DA:250,0 +DA:253,0 +DA:256,0 +DA:259,0 +DA:262,0 +DA:265,0 +DA:268,0 +DA:271,0 +DA:274,0 +DA:277,0 +DA:280,0 +DA:283,0 +DA:286,0 +DA:289,0 +DA:292,0 +DA:295,0 +DA:298,0 +DA:301,0 +DA:304,0 +DA:307,0 +DA:310,0 +DA:313,0 +DA:316,0 +DA:319,0 +DA:322,0 +DA:325,0 +DA:328,0 +DA:331,0 +DA:334,0 +DA:337,0 +DA:340,0 +DA:343,0 +DA:346,0 +DA:349,0 +DA:352,0 +DA:355,0 +DA:358,0 +DA:361,0 +DA:364,0 +DA:367,0 +DA:370,0 +DA:372,0 +DA:375,0 +DA:377,0 +DA:380,0 +DA:383,0 +DA:386,0 +DA:389,0 +DA:392,0 +LF:132 +LH:0 +end_of_record +SF:lib\model\platform_info\platform_info_imp\dummy_platform_info.dart +DA:5,0 +DA:8,0 +DA:11,0 +DA:14,0 +DA:17,0 +DA:20,0 +LF:6 +LH:0 +end_of_record +SF:lib\model\push_request.dart +DA:20,4 +DA:33,16 +DA:34,13 +DA:37,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,1 +DA:53,1 +DA:54,1 +DA:55,1 +DA:56,1 +DA:57,1 +DA:58,1 +DA:59,0 +DA:63,4 +DA:64,7 +DA:66,1 +DA:67,2 +DA:69,2 +DA:71,6 +DA:72,8 +DA:73,4 +DA:74,4 +DA:77,2 +DA:79,2 +LF:26 +LH:25 +end_of_record +SF:lib\model\push_request.g.dart +DA:9,2 +DA:10,1 +DA:11,1 +DA:12,2 +DA:13,1 +DA:14,1 +DA:15,1 +DA:16,2 +DA:17,1 +DA:18,1 +DA:19,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:27,2 +DA:28,1 +DA:29,1 +DA:30,2 +DA:31,1 +DA:32,1 +DA:33,1 +LF:23 +LH:23 +end_of_record +SF:lib\utils\riverpod_providers.dart +DA:28,0 +DA:29,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:36,0 +DA:38,0 +DA:39,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:46,0 +DA:47,0 +DA:48,0 +DA:51,0 +DA:54,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:74,0 +DA:77,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:90,0 +DA:91,0 +DA:94,0 +DA:95,0 +DA:96,0 +DA:97,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:108,0 +DA:109,0 +DA:116,0 +DA:118,0 +DA:119,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:128,0 +DA:130,0 +LF:55 +LH:0 +end_of_record +SF:lib\model\push_request_queue.dart +DA:10,4 +DA:15,4 +DA:16,8 +DA:17,4 +DA:20,2 +DA:21,2 +DA:22,0 +DA:25,2 +DA:28,0 +DA:30,0 +DA:32,3 +DA:34,0 +DA:36,6 +DA:38,2 +DA:39,12 +DA:41,4 +DA:44,3 +DA:46,3 +DA:48,0 +DA:50,12 +DA:52,5 +DA:54,6 +DA:56,1 +DA:58,2 +DA:61,2 +DA:62,14 +DA:64,2 +DA:65,6 +DA:67,7 +DA:68,3 +DA:74,0 +DA:75,0 +DA:77,4 +DA:79,4 +LF:34 +LH:27 +end_of_record +SF:lib\model\push_request_queue.g.dart +DA:9,2 +DA:10,2 +DA:11,4 +DA:12,4 +DA:13,2 +DA:15,2 +DA:16,2 +DA:17,2 +LF:8 +LH:8 +end_of_record +SF:lib\model\tokens\push_token.dart +DA:18,2 +DA:35,5 +DA:36,4 +DA:37,5 +DA:38,4 +DA:39,0 +DA:40,4 +DA:42,2 +DA:43,4 +DA:44,6 +DA:45,6 +DA:48,2 +DA:49,14 +DA:50,4 +DA:52,4 +DA:58,2 +DA:59,7 +DA:60,2 +DA:63,3 +DA:88,3 +DA:89,2 +DA:90,6 +DA:92,2 +DA:117,2 +DA:118,2 +DA:119,2 +DA:120,2 +DA:121,2 +DA:122,2 +DA:123,2 +DA:124,2 +DA:125,2 +DA:126,2 +DA:127,2 +DA:128,2 +DA:129,2 +DA:130,2 +DA:131,2 +DA:132,2 +DA:133,2 +DA:134,2 +DA:135,2 +DA:136,2 +DA:137,3 +DA:141,2 +DA:142,7 +DA:144,0 +DA:145,0 +DA:147,1 +DA:149,2 +DA:150,1 +DA:151,2 +DA:152,1 +DA:153,2 +DA:154,1 +DA:155,1 +DA:156,1 +DA:157,1 +DA:158,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:164,1 +DA:167,1 +DA:168,1 +DA:169,1 +DA:170,1 +DA:171,1 +DA:172,1 +DA:173,5 +DA:174,1 +DA:175,1 +DA:176,1 +DA:177,1 +DA:178,1 +DA:181,0 +DA:186,1 +DA:187,1 +DA:188,2 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,0 +DA:193,0 +DA:194,0 +DA:196,1 +DA:199,2 +LF:87 +LH:80 +end_of_record +SF:lib\model\tokens\push_token.g.dart +DA:9,2 +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,3 +DA:15,1 +DA:17,2 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:23,1 +DA:24,1 +DA:25,1 +DA:26,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:39,1 +DA:42,2 +DA:43,1 +DA:44,1 +DA:45,1 +DA:46,1 +DA:47,1 +DA:48,1 +DA:49,1 +DA:50,1 +DA:51,1 +DA:52,2 +DA:53,1 +DA:54,1 +DA:55,1 +DA:56,2 +DA:57,1 +DA:58,2 +DA:59,1 +DA:60,1 +DA:61,1 +DA:62,1 +DA:63,1 +LF:50 +LH:50 +end_of_record +SF:lib\model\tokens\day_password_token.dart +DA:19,1 +DA:34,2 +DA:35,2 +DA:37,0 +DA:53,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:70,1 +DA:71,1 +DA:72,1 +DA:73,2 +DA:74,1 +DA:75,2 +DA:76,2 +DA:80,0 +DA:81,0 +DA:82,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:89,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:95,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:111,0 +DA:116,0 +DA:117,0 +DA:119,0 +DA:121,0 +LF:53 +LH:10 +end_of_record +SF:lib\model\tokens\day_password_token.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:44,0 +LF:33 +LH:0 +end_of_record +SF:lib\utils\crypto_utils.dart +DA:34,1 +DA:35,1 +DA:36,1 +DA:37,1 +DA:38,1 +DA:40,1 +DA:41,1 +DA:42,1 +DA:43,1 +DA:46,3 +DA:48,1 +DA:52,1 +DA:54,1 +DA:56,4 +DA:57,1 +DA:59,2 +DA:62,1 +DA:64,2 +DA:67,1 +DA:70,1 +DA:72,1 +DA:73,1 +DA:76,3 +DA:80,2 +DA:81,2 +DA:83,2 +DA:84,2 +DA:85,4 +DA:86,4 +DA:88,6 +DA:93,2 +DA:94,4 +DA:95,4 +DA:96,4 +DA:99,5 +DA:100,6 +DA:101,6 +DA:102,10 +DA:105,2 +DA:107,2 +DA:114,3 +DA:115,3 +DA:116,3 +DA:117,3 +LF:44 +LH:44 +end_of_record +SF:lib\utils\utils.dart +DA:37,1 +DA:38,5 +DA:48,1 +DA:49,1 +DA:51,3 +DA:52,7 +DA:55,1 +DA:58,4 +DA:59,8 +DA:60,8 +DA:65,2 +DA:73,8 +DA:74,8 +DA:75,8 +DA:76,48 +DA:77,16 +DA:80,5 +DA:81,15 +DA:85,1 +DA:86,2 +DA:87,0 +DA:88,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:97,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:112,0 +DA:113,0 +DA:116,0 +DA:117,0 +DA:122,0 +DA:124,0 +DA:126,0 +DA:132,0 +LF:43 +LH:20 +end_of_record +SF:lib\model\tokens\otp_token.dart +DA:10,4 +DA:40,0 +DA:42,0 +LF:3 +LH:1 +end_of_record +SF:lib\model\tokens\hotp_token.dart +DA:16,4 +DA:30,8 +DA:32,3 +DA:33,3 +DA:34,3 +DA:35,3 +DA:36,3 +DA:37,6 +DA:41,4 +DA:43,2 +DA:58,2 +DA:59,1 +DA:60,2 +DA:61,2 +DA:62,2 +DA:63,2 +DA:64,2 +DA:65,2 +DA:66,2 +DA:67,2 +DA:68,2 +DA:69,2 +DA:70,3 +DA:73,0 +DA:75,0 +DA:78,2 +DA:79,3 +DA:80,5 +DA:83,2 +DA:84,2 +DA:85,2 +DA:86,2 +DA:87,4 +DA:88,2 +DA:89,4 +DA:90,2 +DA:91,2 +DA:92,2 +DA:93,2 +DA:96,0 +DA:101,0 +DA:103,0 +LF:42 +LH:37 +end_of_record +SF:lib\model\tokens\hotp_token.g.dart +DA:9,0 +DA:10,0 +DA:11,0 +DA:12,0 +DA:13,0 +DA:14,0 +DA:15,0 +DA:16,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:38,0 +LF:28 +LH:0 +end_of_record +SF:lib\utils\custom_int_buffer.dart +DA:9,4 +DA:14,3 +DA:15,5 +DA:16,3 +DA:19,2 +DA:20,2 +DA:21,0 +DA:24,6 +DA:25,0 +DA:28,2 +DA:31,3 +DA:32,14 +DA:33,6 +DA:36,3 +DA:38,6 +DA:40,2 +DA:42,2 +LF:17 +LH:15 +end_of_record +SF:lib\utils\custom_int_buffer.g.dart +DA:9,1 +DA:10,1 +DA:11,4 +DA:13,1 +DA:14,1 +DA:15,1 +LF:6 +LH:6 +end_of_record +SF:lib\utils\rsa_utils.dart +DA:34,66 +DA:43,2 +DA:44,6 +DA:45,6 +DA:46,6 +DA:48,2 +DA:58,2 +DA:59,2 +DA:60,6 +DA:61,6 +DA:63,4 +DA:78,1 +DA:79,3 +DA:81,2 +DA:83,3 +DA:85,2 +DA:86,0 +DA:87,0 +DA:93,2 +DA:95,3 +DA:97,3 +DA:98,3 +DA:100,1 +DA:115,1 +DA:116,1 +DA:117,1 +DA:118,2 +DA:119,2 +DA:121,1 +DA:122,3 +DA:123,3 +DA:125,2 +DA:127,1 +DA:128,1 +DA:129,1 +DA:130,2 +DA:151,2 +DA:152,2 +DA:153,4 +DA:154,6 +DA:155,6 +DA:156,6 +DA:157,6 +DA:158,6 +DA:159,14 +DA:160,14 +DA:161,10 +DA:163,4 +DA:184,1 +DA:185,3 +DA:186,3 +DA:187,3 +DA:188,3 +DA:189,3 +DA:191,1 +DA:195,1 +DA:196,1 +DA:197,2 +DA:201,2 +DA:202,0 +DA:203,0 +DA:215,0 +DA:217,0 +DA:220,0 +DA:222,0 +DA:223,0 +DA:231,0 +DA:237,2 +DA:238,2 +DA:239,4 +DA:240,2 +DA:245,2 +DA:246,12 +DA:248,2 +DA:250,6 +DA:253,0 +DA:254,0 +DA:257,1 +DA:258,1 +DA:259,2 +DA:261,2 +LF:81 +LH:69 +end_of_record +SF:lib\model\tokens\totp_token.dart +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,2 +DA:23,1 +DA:24,2 +DA:25,1 +DA:29,1 +DA:43,1 +DA:44,2 +DA:46,1 +DA:61,1 +DA:62,0 +DA:63,0 +DA:64,0 +DA:65,0 +DA:66,0 +DA:67,0 +DA:68,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,1 +DA:77,0 +DA:79,0 +DA:82,1 +DA:83,2 +DA:84,3 +DA:85,3 +DA:88,1 +DA:89,1 +DA:90,1 +DA:91,1 +DA:92,2 +DA:93,1 +DA:94,1 +DA:95,2 +DA:96,1 +DA:97,1 +DA:98,1 +DA:101,0 +DA:106,2 +DA:108,0 +DA:109,0 +DA:110,0 +DA:113,0 +DA:114,0 +DA:115,0 +DA:118,2 +LF:50 +LH:30 +end_of_record +SF:lib\model\tokens\totp_token.g.dart +DA:9,2 +DA:10,1 +DA:11,1 +DA:12,1 +DA:13,1 +DA:14,2 +DA:15,1 +DA:16,1 +DA:17,1 +DA:18,1 +DA:19,1 +DA:20,1 +DA:21,1 +DA:22,1 +DA:25,2 +DA:26,1 +DA:27,1 +DA:28,1 +DA:29,1 +DA:30,1 +DA:31,1 +DA:32,1 +DA:33,1 +DA:34,1 +DA:35,2 +DA:36,1 +DA:37,1 +DA:38,1 +LF:28 +LH:28 +end_of_record +SF:lib\repo\preference_settings_repository.dart +DA:19,0 +DA:20,0 +DA:23,0 +DA:25,0 +DA:26,0 +DA:27,0 +DA:28,0 +DA:29,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:44,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:53,0 +DA:54,0 +DA:56,0 +DA:57,0 +DA:59,0 +DA:60,0 +DA:61,0 +LF:25 +LH:0 +end_of_record +SF:lib\repo\preference_token_folder_repository.dart +DA:12,0 +DA:14,0 +DA:17,0 +DA:18,0 +DA:19,0 +DA:20,0 +DA:23,0 +DA:24,0 +DA:28,0 +DA:31,0 +DA:32,0 +DA:33,0 +DA:34,0 +DA:36,0 +LF:14 +LH:0 +end_of_record +SF:lib\utils\logger.dart +DA:22,0 +DA:27,0 +DA:28,0 +DA:29,15 +DA:30,5 +DA:38,5 +DA:40,5 +DA:41,5 +DA:46,0 +DA:47,0 +DA:48,0 +DA:49,0 +DA:51,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,5 +DA:78,0 +DA:81,0 +DA:82,0 +DA:83,0 +DA:88,5 +DA:93,0 +DA:99,15 +DA:100,5 +DA:101,15 +DA:106,0 +DA:108,0 +DA:110,0 +DA:115,5 +DA:116,10 +DA:117,10 +DA:118,0 +DA:120,5 +DA:123,1 +DA:124,2 +DA:125,2 +DA:126,0 +DA:128,1 +DA:131,0 +DA:132,0 +DA:133,0 +DA:134,0 +DA:135,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:148,0 +DA:149,0 +DA:151,0 +DA:153,0 +DA:157,0 +DA:158,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:187,0 +DA:189,0 +DA:195,0 +DA:196,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:216,5 +DA:217,5 +DA:218,5 +DA:219,5 +DA:220,5 +DA:223,5 +DA:224,10 +DA:225,5 +DA:228,0 +DA:229,0 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:238,0 +DA:239,0 +DA:242,0 +DA:246,5 +DA:247,10 +DA:250,5 +DA:251,10 +DA:252,0 +DA:255,5 +DA:256,10 +DA:257,0 +DA:258,0 +DA:261,5 +DA:262,0 +DA:263,0 +DA:268,10 +DA:269,5 +DA:271,0 +DA:272,0 +DA:273,0 +DA:274,0 +DA:276,5 +DA:279,5 +DA:284,5 +DA:286,10 +DA:289,5 +DA:291,10 +DA:294,0 +DA:296,0 +DA:301,0 +DA:302,0 +DA:303,0 +DA:304,0 +DA:305,0 +DA:306,0 +DA:307,0 +DA:309,0 +DA:310,0 +DA:311,0 +DA:312,0 +DA:313,0 +DA:322,0 +DA:323,0 +DA:324,0 +DA:325,0 +DA:326,0 +DA:327,0 +DA:334,0 +DA:335,0 +DA:336,0 +DA:337,0 +DA:342,5 +DA:343,15 +DA:344,10 +DA:345,8 +DA:346,5 +DA:348,5 +DA:350,15 +DA:351,5 +DA:352,25 +DA:353,25 +DA:354,15 +DA:359,0 +DA:360,0 +DA:361,0 +DA:362,0 +DA:363,0 +DA:364,0 +DA:365,0 +DA:366,0 +DA:367,0 +DA:368,0 +DA:369,0 +DA:370,0 +DA:371,0 +DA:372,0 +DA:373,0 +DA:374,0 +DA:375,0 +DA:376,0 +DA:377,0 +DA:378,0 +DA:379,0 +DA:380,0 +DA:381,0 +DA:382,0 +DA:383,0 +DA:384,0 +DA:385,0 +DA:386,0 +DA:387,0 +DA:388,0 +DA:389,0 +DA:390,0 +DA:391,0 +DA:392,0 +DA:395,0 +DA:396,0 +DA:397,0 +DA:398,0 +DA:399,0 +DA:400,0 +DA:401,0 +DA:402,0 +DA:403,0 +DA:404,0 +DA:405,0 +DA:406,0 +DA:408,0 +LF:217 +LH:52 +end_of_record +SF:lib\repo\secure_token_repository.dart +DA:33,32 +DA:36,0 +DA:40,0 +DA:53,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:65,0 +DA:70,0 +DA:72,0 +DA:82,0 +DA:84,0 +DA:86,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:101,0 +DA:102,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:125,0 +DA:126,0 +DA:128,0 +DA:131,0 +DA:135,0 +DA:137,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:151,0 +DA:153,0 +DA:155,0 +DA:158,0 +DA:168,0 +DA:170,0 +DA:175,0 +DA:177,0 +LF:48 +LH:1 +end_of_record +SF:lib\state_notifiers\app_state_notifier.dart +DA:6,2 +DA:8,1 +DA:9,1 +LF:3 +LH:3 +end_of_record +SF:lib\state_notifiers\deeplink_notifier.dart +DA:13,0 +DA:14,0 +DA:15,0 +DA:18,0 +DA:20,0 +DA:21,0 +DA:26,0 +DA:30,0 +DA:31,0 +DA:32,0 +DA:34,0 +DA:35,0 +DA:36,0 +DA:37,0 +DA:42,0 +DA:45,0 +DA:47,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:52,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +LF:25 +LH:0 +end_of_record +SF:lib\state_notifiers\push_request_notifier.dart +DA:41,1 +DA:48,0 +DA:50,1 +DA:51,2 +DA:55,1 +DA:56,2 +DA:57,1 +DA:58,1 +DA:59,1 +DA:61,0 +DA:64,1 +DA:68,1 +DA:69,2 +DA:70,1 +DA:71,1 +DA:72,1 +DA:74,0 +DA:77,1 +DA:81,0 +DA:83,1 +DA:84,1 +DA:86,3 +DA:87,2 +DA:90,3 +DA:91,2 +DA:92,1 +DA:94,3 +DA:95,2 +DA:105,1 +DA:106,1 +DA:107,1 +DA:110,2 +DA:111,1 +DA:114,4 +DA:115,2 +DA:116,0 +DA:119,0 +LF:37 +LH:31 +end_of_record +SF:lib\utils\firebase_utils.dart +DA:15,1 +DA:17,1 +DA:18,1 +DA:22,0 +DA:27,0 +DA:30,0 +DA:31,0 +DA:34,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:43,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:53,0 +DA:56,0 +DA:57,0 +DA:60,0 +DA:62,0 +DA:63,0 +DA:65,0 +DA:66,0 +DA:69,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:76,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:85,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:93,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:101,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:109,0 +DA:118,0 +DA:121,0 +DA:122,0 +DA:123,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:131,0 +DA:132,0 +DA:137,0 +DA:139,0 +DA:145,0 +LF:59 +LH:3 +end_of_record +SF:lib\utils\network_utils.dart +DA:31,48 +DA:36,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:41,0 +DA:42,0 +DA:44,0 +DA:47,0 +DA:48,0 +DA:51,0 +DA:56,0 +DA:57,0 +DA:58,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:66,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:74,0 +DA:76,0 +DA:80,0 +DA:81,0 +DA:82,0 +DA:85,0 +DA:86,0 +DA:89,0 +DA:92,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:103,0 +DA:105,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:115,0 +DA:117,0 +DA:119,0 +DA:120,0 +DA:121,0 +DA:125,0 +DA:127,0 +DA:128,0 +DA:129,0 +DA:130,0 +DA:131,0 +DA:132,0 +DA:136,0 +DA:137,0 +DA:140,0 +DA:144,0 +LF:62 +LH:1 +end_of_record +SF:lib\utils\push_provider.dart +DA:54,0 +DA:58,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:62,0 +DA:63,0 +DA:64,0 +DA:70,0 +DA:76,0 +DA:79,0 +DA:82,0 +DA:87,0 +DA:95,0 +DA:97,0 +DA:99,0 +DA:103,0 +DA:104,0 +DA:105,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:113,0 +DA:114,0 +DA:120,0 +DA:121,0 +DA:122,0 +DA:124,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:129,0 +DA:130,0 +DA:138,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:148,0 +DA:150,0 +DA:152,0 +DA:155,0 +DA:156,0 +DA:159,0 +DA:161,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:177,0 +DA:179,0 +DA:181,0 +DA:184,0 +DA:185,0 +DA:188,0 +DA:189,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:197,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:206,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:225,0 +DA:226,0 +DA:227,0 +DA:228,0 +DA:229,0 +DA:233,0 +DA:236,0 +DA:237,0 +DA:238,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:247,0 +DA:248,0 +DA:255,0 +DA:256,0 +DA:258,0 +DA:260,0 +DA:261,0 +DA:262,0 +DA:264,0 +DA:267,0 +DA:268,0 +DA:275,0 +DA:276,0 +DA:278,0 +DA:279,0 +DA:282,0 +DA:283,0 +DA:285,0 +DA:286,0 +DA:287,0 +DA:291,0 +DA:292,0 +DA:293,0 +DA:297,0 +DA:298,0 +DA:301,0 +DA:307,0 +DA:308,0 +DA:310,0 +DA:312,0 +DA:314,0 +DA:315,0 +DA:316,0 +DA:321,0 +DA:334,0 +DA:340,0 +DA:344,0 +DA:353,0 +DA:355,0 +DA:358,0 +DA:363,0 +DA:364,0 +DA:365,0 +DA:366,0 +DA:368,0 +DA:369,0 +DA:371,0 +DA:377,0 +LF:143 +LH:0 +end_of_record +SF:lib\state_notifiers\settings_notifier.dart +DA:16,1 +DA:20,1 +DA:21,1 +DA:23,1 +DA:24,3 +DA:25,3 +DA:26,3 +DA:30,1 +DA:31,3 +DA:32,3 +DA:35,1 +DA:36,2 +DA:37,3 +DA:38,3 +DA:39,1 +DA:42,0 +DA:43,0 +DA:44,0 +DA:45,0 +DA:48,0 +DA:49,0 +DA:50,0 +DA:51,0 +DA:54,0 +DA:55,0 +DA:56,0 +DA:57,0 +DA:60,1 +DA:61,2 +DA:62,3 +DA:63,1 +DA:66,1 +DA:67,2 +DA:68,3 +DA:69,1 +DA:72,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:78,0 +DA:79,0 +DA:80,0 +DA:81,0 +DA:84,1 +DA:85,2 +DA:86,3 +DA:87,1 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:96,1 +DA:97,2 +DA:98,3 +DA:99,1 +DA:102,1 +DA:103,2 +DA:104,2 +DA:105,3 +DA:106,1 +DA:109,1 +DA:110,2 +DA:111,3 +DA:112,1 +LF:64 +LH:40 +end_of_record +SF:lib\state_notifiers\token_folder_notifier.dart +DA:11,1 +DA:13,1 +DA:14,1 +DA:17,8 +DA:19,1 +DA:20,3 +DA:21,2 +DA:22,1 +DA:23,0 +DA:28,1 +DA:29,2 +DA:30,1 +DA:31,2 +DA:34,1 +DA:35,2 +DA:36,1 +DA:37,2 +DA:40,1 +DA:41,3 +DA:42,1 +DA:43,2 +DA:46,1 +DA:47,2 +DA:48,1 +DA:49,2 +LF:25 +LH:24 +end_of_record +SF:lib\state_notifiers\token_notifier.dart +DA:45,1 +DA:58,1 +DA:59,1 +DA:60,1 +DA:62,1 +DA:65,1 +DA:66,3 +DA:67,2 +DA:68,1 +DA:69,0 +DA:70,0 +DA:73,0 +DA:75,2 +DA:76,4 +DA:81,1 +DA:82,3 +DA:83,2 +DA:84,3 +DA:88,1 +DA:91,3 +DA:92,2 +DA:93,1 +DA:94,1 +DA:95,1 +DA:98,4 +DA:99,2 +DA:100,2 +DA:101,1 +DA:110,1 +DA:111,1 +DA:114,2 +DA:118,5 +DA:119,2 +DA:120,3 +DA:124,1 +DA:125,6 +DA:128,1 +DA:129,3 +DA:130,3 +DA:131,2 +DA:134,1 +DA:135,3 +DA:136,2 +DA:139,1 +DA:140,3 +DA:141,2 +DA:145,1 +DA:146,3 +DA:147,1 +DA:150,0 +DA:151,0 +DA:152,0 +DA:155,0 +DA:156,0 +DA:159,0 +DA:162,0 +DA:164,0 +DA:167,1 +DA:170,1 +DA:177,2 +DA:179,3 +DA:181,0 +DA:182,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:194,1 +DA:195,0 +DA:196,0 +DA:197,0 +DA:201,1 +DA:202,0 +DA:205,1 +DA:208,0 +DA:210,0 +DA:211,0 +DA:216,1 +DA:217,1 +DA:218,9 +DA:219,5 +DA:221,0 +DA:224,1 +DA:225,2 +DA:226,1 +DA:227,1 +DA:228,1 +DA:229,1 +DA:230,1 +DA:233,1 +DA:234,0 +DA:236,1 +DA:237,0 +DA:238,5 +DA:241,0 +DA:244,0 +DA:248,1 +DA:250,2 +DA:251,0 +DA:252,0 +DA:253,0 +DA:259,1 +DA:260,1 +DA:262,4 +DA:266,1 +DA:267,3 +DA:268,8 +DA:271,0 +DA:274,1 +DA:275,1 +DA:277,4 +DA:281,1 +DA:282,2 +DA:283,2 +DA:284,3 +DA:285,1 +DA:286,2 +DA:287,2 +DA:288,2 +DA:289,2 +DA:290,4 +DA:294,2 +DA:295,0 +DA:296,0 +DA:297,0 +DA:298,0 +DA:302,0 +DA:305,1 +DA:306,0 +DA:309,1 +DA:310,2 +DA:312,2 +DA:313,2 +DA:314,2 +DA:315,1 +DA:316,4 +DA:317,1 +DA:319,0 +DA:320,0 +DA:325,2 +DA:328,2 +DA:329,1 +DA:330,1 +DA:331,1 +DA:332,1 +DA:333,1 +DA:334,2 +DA:335,3 +DA:339,2 +DA:340,2 +DA:342,1 +DA:343,1 +DA:344,0 +DA:345,0 +DA:346,0 +DA:347,0 +DA:350,1 +DA:351,1 +DA:352,1 +DA:356,0 +DA:358,0 +DA:362,0 +DA:363,0 +DA:364,0 +DA:366,0 +DA:367,0 +DA:368,0 +DA:371,0 +DA:375,0 +DA:376,0 +DA:377,0 +DA:378,0 +DA:381,0 +DA:382,0 +DA:383,0 +DA:384,0 +DA:385,0 +DA:388,0 +DA:390,0 +DA:391,0 +DA:392,0 +DA:396,0 +DA:397,0 +DA:403,1 +DA:404,2 +DA:406,4 +DA:407,1 +DA:409,1 +DA:411,2 +DA:412,0 +DA:413,0 +LF:192 +LH:120 +end_of_record +SF:lib\utils\qr_parser.dart +DA:27,17 +DA:30,1 +DA:31,1 +DA:32,1 +DA:40,2 +DA:41,1 +DA:44,2 +DA:48,1 +DA:49,2 +DA:50,2 +DA:51,2 +DA:52,1 +DA:53,2 +DA:54,0 +DA:57,1 +DA:60,1 +DA:64,0 +DA:77,0 +DA:79,0 +DA:82,0 +DA:85,0 +DA:89,0 +DA:91,0 +DA:93,0 +DA:94,0 +DA:96,0 +DA:100,0 +DA:101,0 +DA:104,0 +DA:105,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:112,0 +DA:113,0 +DA:115,0 +DA:116,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:123,0 +DA:125,0 +DA:126,0 +DA:127,0 +DA:130,0 +DA:131,0 +DA:133,0 +DA:136,0 +DA:137,0 +DA:145,1 +DA:148,1 +DA:151,2 +DA:155,3 +DA:156,3 +DA:158,1 +DA:163,1 +DA:164,1 +DA:165,1 +DA:168,3 +DA:169,0 +DA:172,2 +DA:173,0 +DA:176,3 +DA:178,2 +DA:179,2 +DA:180,2 +DA:181,1 +DA:184,1 +DA:188,1 +DA:191,2 +DA:193,2 +DA:194,1 +DA:197,1 +DA:201,1 +DA:203,1 +DA:206,2 +DA:207,1 +DA:213,3 +DA:214,0 +DA:216,1 +DA:217,1 +DA:218,1 +DA:221,2 +DA:225,1 +DA:227,1 +DA:229,2 +DA:231,2 +DA:234,1 +DA:240,2 +DA:241,1 +DA:242,1 +DA:245,1 +DA:250,4 +DA:252,2 +DA:254,1 +DA:256,2 +DA:258,1 +DA:261,1 +DA:263,0 +DA:264,0 +DA:265,0 +DA:269,0 +DA:270,0 +DA:271,0 +DA:274,0 +DA:278,0 +DA:279,0 +DA:280,0 +DA:283,0 +DA:287,0 +DA:288,0 +DA:289,0 +DA:292,0 +DA:301,1 +DA:304,2 +DA:305,1 +DA:308,1 +DA:309,1 +DA:310,1 +DA:311,1 +DA:314,1 +DA:316,0 +DA:323,1 +DA:325,2 +DA:328,1 +DA:329,1 +DA:336,1 +DA:337,6 +LF:128 +LH:75 +end_of_record +SF:lib\utils\customizations.dart +DA:25,0 +DA:26,0 +LF:2 +LH:0 +end_of_record +SF:lib\utils\view_utils.dart +DA:7,0 +DA:11,0 +DA:12,0 +DA:15,0 +DA:16,0 +DA:20,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:27,0 +DA:28,0 +LF:11 +LH:0 +end_of_record +SF:lib\widgets\two_step_dialog.dart +DA:36,0 +DA:42,0 +DA:43,0 +DA:55,0 +DA:57,0 +DA:58,0 +DA:61,0 +DA:63,0 +DA:65,0 +DA:68,0 +DA:70,0 +DA:71,0 +DA:72,0 +DA:73,0 +DA:75,0 +DA:77,0 +DA:78,0 +DA:82,0 +DA:83,0 +DA:84,0 +DA:85,0 +DA:86,0 +DA:87,0 +DA:98,0 +DA:100,0 +DA:103,0 +DA:105,0 +DA:106,0 +DA:107,0 +DA:111,0 +DA:112,0 +DA:114,0 +DA:115,0 +DA:116,0 +DA:121,0 +LF:35 +LH:0 +end_of_record +SF:lib\utils\app_customizer.dart +DA:13,0 +DA:33,16 +DA:64,16 +DA:120,0 +DA:139,0 +DA:140,0 +DA:141,0 +DA:142,0 +DA:143,0 +DA:144,0 +DA:145,0 +DA:146,0 +DA:147,0 +DA:148,0 +DA:149,0 +DA:150,0 +DA:151,0 +DA:152,0 +DA:153,0 +DA:154,0 +DA:155,0 +DA:156,0 +DA:159,0 +DA:160,0 +DA:161,0 +DA:162,0 +DA:163,0 +DA:164,0 +DA:165,0 +DA:166,0 +DA:167,0 +DA:168,0 +DA:169,0 +DA:170,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:179,0 +DA:180,0 +DA:181,0 +DA:182,0 +DA:183,0 +DA:184,0 +DA:185,0 +DA:186,0 +DA:187,0 +DA:188,0 +DA:189,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:199,0 +DA:200,0 +DA:201,0 +DA:202,0 +DA:203,0 +DA:204,0 +DA:205,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:213,0 +DA:214,0 +DA:215,0 +DA:258,0 +DA:265,0 +DA:266,0 +DA:268,0 +DA:269,0 +DA:271,0 +DA:273,0 +DA:274,0 +DA:277,0 +DA:281,0 +DA:290,0 +DA:291,0 +DA:292,0 +DA:293,0 +DA:294,0 +DA:295,0 +DA:296,0 +DA:299,0 +DA:301,0 +DA:303,0 +DA:304,0 +DA:305,0 +DA:306,0 +DA:307,0 +DA:308,0 +DA:309,0 +DA:312,0 +DA:313,0 +DA:314,0 +DA:315,0 +DA:316,0 +DA:317,0 +DA:318,0 +DA:319,0 +DA:324,0 +DA:325,0 +DA:327,0 +DA:328,0 +DA:329,0 +DA:330,0 +DA:331,0 +DA:332,0 +DA:333,0 +DA:334,0 +DA:335,0 +DA:336,0 +DA:337,0 +DA:338,0 +DA:339,0 +DA:340,0 +DA:342,0 +DA:343,0 +DA:344,0 +DA:345,0 +DA:346,0 +DA:347,0 +DA:350,0 +DA:351,0 +DA:352,0 +DA:353,0 +DA:354,0 +DA:357,0 +DA:358,0 +DA:359,0 +DA:361,0 +DA:362,0 +DA:363,0 +DA:364,0 +DA:365,0 +DA:367,0 +DA:368,0 +DA:369,0 +DA:370,0 +DA:371,0 +DA:372,0 +DA:373,0 +DA:375,0 +DA:376,0 +DA:377,0 +DA:378,0 +DA:379,0 +DA:380,0 +DA:382,0 +DA:383,0 +DA:384,0 +DA:387,0 +DA:388,0 +DA:393,0 +DA:394,0 +DA:395,0 +DA:398,0 +DA:399,0 +DA:404,0 +DA:405,0 +DA:406,0 +DA:409,0 +DA:410,0 +DA:414,0 +DA:415,0 +DA:418,0 +DA:419,0 +DA:424,0 +DA:425,0 +DA:426,0 +DA:427,0 +DA:428,0 +DA:429,0 +DA:439,0 +DA:446,0 +DA:447,0 +DA:448,0 +DA:449,0 +DA:450,0 +DA:451,0 +DA:454,0 +DA:455,0 +DA:456,0 +DA:457,0 +DA:458,0 +DA:459,0 +LF:195 +LH:2 +end_of_record +SF:lib\views\settings_view\settings_view_widgets\send_error_dialog.dart +DA:10,16 +DA:12,0 +DA:13,0 +DA:17,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:25,0 +DA:26,0 +DA:30,0 +DA:32,0 +DA:33,0 +DA:35,0 +DA:37,0 +DA:38,0 +DA:39,0 +DA:40,0 +DA:45,0 +DA:47,0 +DA:48,0 +DA:51,0 +DA:54,0 +DA:57,0 +DA:59,0 +DA:60,0 +DA:61,0 +DA:73,0 +DA:74,0 +DA:75,0 +DA:76,0 +DA:80,0 +DA:82,0 +DA:84,0 +DA:85,0 +DA:97,0 +DA:99,0 +DA:101,0 +DA:103,0 +DA:104,0 +DA:106,0 +DA:107,0 +DA:108,0 +DA:109,0 +DA:113,0 +LF:47 +LH:1 +end_of_record +SF:lib\widgets\default_dialog.dart +DA:11,0 +DA:19,0 +DA:20,0 +DA:21,0 +DA:22,0 +DA:23,0 +DA:24,0 +DA:30,0 +DA:31,0 +DA:32,0 +LF:10 +LH:0 +end_of_record diff --git a/create_arb_files.sh b/create_arb_files.sh old mode 100755 new mode 100644 diff --git a/create_coverage_report.sh b/create_coverage_report.sh old mode 100755 new mode 100644 diff --git a/create_messages_from_arb.sh b/create_messages_from_arb.sh old mode 100755 new mode 100644 diff --git a/customisation_file.json b/customisation_file.json new file mode 100644 index 000000000..e21133a11 --- /dev/null +++ b/customisation_file.json @@ -0,0 +1,32 @@ +{ + "appName": "PCAS Authenticator", + "websiteLink": "https://anywebsitelink.it/", + "appIconBytes": "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", + "appImageBytes": "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", + "lightTheme": { + "primaryColor": 4286850500, + "onPrimary": 4292862013, + "themeColor": 4289429460, + "backgroundColor": 4291413728, + "foregroundColor": 4280818995, + "deleteColor": 4292889901, + "renameColor": 4285173733, + "lockColor": 4294956595, + "tileIconColor": 4279703343, + "subtitleColor": 4279373850, + "shadowColor": 4278190080 + }, + "darkTheme": { + "primaryColor": 4286850500, + "onPrimary": 4292862013, + "themeColor": 4280946247, + "backgroundColor": 4281214291, + "foregroundColor": 4292861170, + "deleteColor": 4291640340, + "renameColor": 4283596507, + "lockColor": 4292129568, + "tileIconColor": 4286142671, + "subtitleColor": 4289173180, + "shadowColor": 4291024608 + } +} \ No newline at end of file diff --git a/integration_test/add_tokens_test.dart b/integration_test/add_tokens_test.dart new file mode 100644 index 000000000..0c6a3e213 --- /dev/null +++ b/integration_test/add_tokens_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacyidea_authenticator/main_netknights.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; +import 'package:privacyidea_authenticator/state_notifiers/settings_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_folder_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_notifier.dart'; +import 'package:privacyidea_authenticator/utils/app_customizer.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/views/add_token_manually_view/add_token_manually_view.dart'; +import 'package:privacyidea_authenticator/views/add_token_manually_view/add_token_manually_view_widgets/labeled_dropdown_button.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/app_bar_item.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/drag_target_divider.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/folder_widgets/token_folder_widget.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/day_password_token_widget.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/hotp_token_widget.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/token_widget_base.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/totp_token_widget.dart'; + +import '../test/tests_app_wrapper.dart'; +import '../test/tests_app_wrapper.mocks.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late final MockSettingsRepository mockSettingsRepository; + late final MockTokenRepository mockTokenRepository; + late final MockTokenFolderRepository mockTokenFolderRepository; + setUp(() { + mockSettingsRepository = MockSettingsRepository(); + when(mockSettingsRepository.loadSettings()).thenAnswer((_) async => SettingsState()); + when(mockSettingsRepository.saveSettings(any)).thenAnswer((_) async => true); + mockTokenRepository = MockTokenRepository(); + when(mockTokenRepository.loadTokens()).thenAnswer((_) async => []); + when(mockTokenRepository.saveOrReplaceTokens(any)).thenAnswer((_) async => []); + when(mockTokenRepository.deleteTokens(any)).thenAnswer((_) async => []); + mockTokenFolderRepository = MockTokenFolderRepository(); + when(mockTokenFolderRepository.loadFolders()).thenAnswer((_) async => []); + when(mockTokenFolderRepository.saveOrReplaceFolders(any)).thenAnswer((_) async => []); + }); + testWidgets( + 'Add Tokens Test', + (tester) async { + await tester.pumpWidget(TestsAppWrapper( + overrides: [ + settingsProvider.overrideWith((ref) => SettingsNotifier(repository: mockSettingsRepository)), + tokenProvider.overrideWith((ref) => TokenNotifier(repository: mockTokenRepository)), + tokenFolderProvider.overrideWith((ref) => TokenFolderNotifier(repository: mockTokenFolderRepository)), + ], + child: PrivacyIDEAAuthenticator(customization: ApplicationCustomization.defaultCustomization), + )); + + await _introToMainView(tester); + expectMainViewIsEmptyAndCorrect(); + await _addHotpToken(tester); + expect(find.byType(HOTPTokenWidget), findsOneWidget); + await _addTotpToken(tester); + expect(find.byType(TOTPTokenWidget), findsOneWidget); + await _addDaypasswordToken(tester); + expect(find.byType(DayPasswordTokenWidget), findsOneWidget); + await _createFolder(tester); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(TokenFolderWidget), findsOneWidget); + expect(find.text('Folder'), findsOneWidget); + expect(find.byType(TokenWidgetBase).hitTestable(), findsNWidgets(3)); + await _moveFolderToTopPosition(tester); + await _moveHotpTokenWidgetIntoFolder(tester); + await _moveDayPasswordTokenWidgetIntoFolder(tester); + expect(find.byType(TOTPTokenWidget).hitTestable(), findsOneWidget); + expect(find.byType(TokenWidgetBase).hitTestable(), findsOneWidget); + await _openFolder(tester); + await pumpUntilFindNWidgets(tester, find.byType(TokenWidgetBase).hitTestable(), 3, const Duration(seconds: 5)); + expect(find.byType(TokenWidgetBase).hitTestable(), findsNWidgets(3)); + }, + timeout: const Timeout(Duration(minutes: 20)), + ); +} + +Future _introToMainView(WidgetTester tester) async { + final Finder finder = find.byType(FloatingActionButton); + await pumpUntilFindNWidgets(tester, finder, 1, const Duration(seconds: 10)); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.tap(find.byType(FloatingActionButton)); + await tester.pump(const Duration(milliseconds: 1000)); +} + +Future _addHotpToken(WidgetTester tester) async { + await tester.pump(); + await tester.tap(find.byIcon(Icons.add_moderator)); + await tester.pump(const Duration(milliseconds: 1000)); + expect(find.byType(AddTokenManuallyView), findsOneWidget); + expect(find.byType(TextField), findsNWidgets(2)); + expect(find.byType(LabeledDropdownButton), findsOneWidget); + expect(find.byType(LabeledDropdownButton), findsOneWidget); + expect(find.byType(LabeledDropdownButton), findsOneWidget); + expect(find.byType(LabeledDropdownButton), findsOneWidget); + expect(find.text('Add token'), findsOneWidget); + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'test'); + await tester.pump(); + await tester.tap(find.text('Secret')); + await tester.pump(); + await tester.enterText(find.byType(TextField).last, 'test'); + await tester.pump(); + await tester.tap(find.text('Add token')); + await tester.pump(const Duration(milliseconds: 1000)); +} + +Future _addTotpToken(WidgetTester tester) async { + await tester.pump(); + await tester.tap(find.byIcon(Icons.add_moderator)); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.tap(find.text('Name')); + await tester.pump(); + await tester.enterText(find.byType(TextField).first, 'test'); + await tester.pump(); + await tester.tap(find.text('Secret')); + await tester.pump(); + await tester.enterText(find.byType(TextField).last, 'test'); + await tester.pump(); + await tester.tap(find.byType(DropdownButton)); + await tester.pump(); + await tester.tap(find.text('TOTP')); + await tester.pump(); + expect(find.byType(DropdownButton), findsNWidgets(2)); + await tester.tap(find.text('Add token')); + await tester.pump(const Duration(milliseconds: 1000)); +} + +Future _addDaypasswordToken(WidgetTester tester) async { + await tester.pump(); + await tester.tap(find.byIcon(Icons.add_moderator)); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.enterText(find.byType(TextField).first, 'test'); + await tester.pump(); + await tester.tap(find.text('Secret')); + await tester.pump(); + await tester.enterText(find.byType(TextField).last, 'test'); + await tester.pump(); + await tester.tap(find.byType(DropdownButton)); + await tester.pump(); + await tester.tap(find.text('DAYPASSWORD')); + await tester.pump(); + await tester.tap(find.text('Add token')); + await tester.pump(const Duration(milliseconds: 1000)); +} + +Future _createFolder(WidgetTester tester) async { + await tester.pump(); + await tester.tap(find.byIcon(Icons.create_new_folder)); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.enterText(find.byType(TextField).first, 'Folder'); + await tester.pump(); + await tester.tap(find.text('Save')); + await tester.pump(); +} + +Future _moveFolderToTopPosition(WidgetTester tester) async { + await tester.pump(); + final tokenFolderPosition = tester.getCenter(find.byType(TokenFolderWidget)); + final gestrue = await tester.startGesture(tokenFolderPosition); + await tester.pump(const Duration(milliseconds: 1000)); + final dragTargetDividerPosition = tester.getCenter(find.byType(DragTargetDivider).first); + await gestrue.moveTo(dragTargetDividerPosition); + await tester.pump(); + await gestrue.up(); + await tester.pump(); +} + +Future _moveHotpTokenWidgetIntoFolder(WidgetTester tester) async { + await tester.pump(); + final tokenWidgetPosition = tester.getCenter(find.byType(HOTPTokenWidget).first); + final gestrue = await tester.startGesture(tokenWidgetPosition); + await tester.pump(const Duration(milliseconds: 1000)); + final tokenFolderPosition = tester.getCenter(find.byType(TokenFolderWidget)); + await gestrue.moveTo(tokenFolderPosition); + await tester.pump(); + await gestrue.up(); + await tester.pump(); +} + +Future _moveDayPasswordTokenWidgetIntoFolder(WidgetTester tester) async { + await tester.pump(); + final tokenWidgetPosition = tester.getCenter(find.byType(DayPasswordTokenWidget).last); + final gestrue = await tester.startGesture(tokenWidgetPosition); + await tester.pump(const Duration(milliseconds: 1000)); + final tokenFolderPosition = tester.getCenter(find.byType(TokenFolderWidget)); + await gestrue.moveTo(tokenFolderPosition); + await tester.pump(); + await gestrue.up(); + await tester.pump(); +} + +Future _openFolder(WidgetTester tester) async { + await pumpUntilFindNWidgets(tester, find.byType(TokenFolderWidget), 1, const Duration(seconds: 5)); + await tester.tap(find.byType(TokenFolderWidget)); + await tester.pump(); +} + +void expectMainViewIsEmptyAndCorrect() { + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(AppBarItem), findsNWidgets(4)); + expect(find.byType(TokenWidgetBase), findsNothing); + expect(find.byType(TokenFolderWidget), findsNothing); + expect(find.text(ApplicationCustomization.defaultCustomization.appName), findsOneWidget); + expect(find.byType(Image), findsOneWidget); +} diff --git a/integration_test/copy_to_clipboard_test.dart b/integration_test/copy_to_clipboard_test.dart new file mode 100644 index 000000000..e61a9fe96 --- /dev/null +++ b/integration_test/copy_to_clipboard_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacyidea_authenticator/main_netknights.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; +import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; +import 'package:privacyidea_authenticator/state_notifiers/settings_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_folder_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_notifier.dart'; +import 'package:privacyidea_authenticator/utils/app_customizer.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; + +import '../test/tests_app_wrapper.dart'; +import '../test/tests_app_wrapper.mocks.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late final MockSettingsRepository mockSettingsRepository; + late final MockTokenRepository mockTokenRepository; + late final MockTokenFolderRepository mockTokenFolderRepository; + setUp(() { + mockSettingsRepository = MockSettingsRepository(); + when(mockSettingsRepository.loadSettings()).thenAnswer((_) async => SettingsState(isFirstRun: false)); + when(mockSettingsRepository.saveSettings(any)).thenAnswer((_) async => true); + mockTokenRepository = MockTokenRepository(); + when(mockTokenRepository.loadTokens()).thenAnswer((_) async => [ + HOTPToken(label: 'test', issuer: 'test', id: 'id', algorithm: Algorithms.SHA256, digits: 6, secret: 'secret', counter: 0), + ]); + when(mockTokenRepository.saveOrReplaceTokens(any)).thenAnswer((_) async => []); + when(mockTokenRepository.deleteTokens(any)).thenAnswer((_) async => []); + mockTokenFolderRepository = MockTokenFolderRepository(); + when(mockTokenFolderRepository.loadFolders()).thenAnswer((_) async => []); + when(mockTokenFolderRepository.saveOrReplaceFolders(any)).thenAnswer((_) async => []); + }); + testWidgets('Copy to Clipboard Test', (tester) async { + await tester.pumpWidget(TestsAppWrapper( + overrides: [ + settingsProvider.overrideWith((ref) => SettingsNotifier(repository: mockSettingsRepository)), + tokenProvider.overrideWith((ref) => TokenNotifier(repository: mockTokenRepository)), + tokenFolderProvider.overrideWith((ref) => TokenFolderNotifier(repository: mockTokenFolderRepository)), + ], + child: PrivacyIDEAAuthenticator(customization: ApplicationCustomization.defaultCustomization), + )); + await tester.pumpAndSettle(); + await pumpUntilFindNWidgets(tester, find.text('356 306'), 1, const Duration(seconds: 10)); + expect(find.text('356 306'), findsOneWidget); + await tester.tap(find.text('356 306')); + await tester.pumpAndSettle(); + expect(find.text('Password "356306" copied to clipboard.'), findsOneWidget); + }, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/integration_test/rename_and_delete_test.dart b/integration_test/rename_and_delete_test.dart new file mode 100644 index 000000000..f6388c26f --- /dev/null +++ b/integration_test/rename_and_delete_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacyidea_authenticator/main_netknights.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; +import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; +import 'package:privacyidea_authenticator/state_notifiers/settings_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_folder_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_notifier.dart'; +import 'package:privacyidea_authenticator/utils/app_customizer.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_delete_action.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/actions/edit_hotp_token_action.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/hotp_token_widget.dart'; + +import '../test/tests_app_wrapper.dart'; +import '../test/tests_app_wrapper.mocks.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late final MockSettingsRepository mockSettingsRepository; + late final MockTokenRepository mockTokenRepository; + late final MockTokenFolderRepository mockTokenFolderRepository; + setUp(() { + mockSettingsRepository = MockSettingsRepository(); + when(mockSettingsRepository.loadSettings()).thenAnswer((_) async => SettingsState(isFirstRun: false)); + when(mockSettingsRepository.saveSettings(any)).thenAnswer((_) async => true); + mockTokenRepository = MockTokenRepository(); + when(mockTokenRepository.loadTokens()).thenAnswer((_) async => [ + HOTPToken(label: 'test', issuer: 'test', id: 'id', algorithm: Algorithms.SHA256, digits: 6, secret: 'secret', counter: 0), + ]); + when(mockTokenRepository.saveOrReplaceTokens(any)).thenAnswer((_) async => []); + when(mockTokenRepository.deleteTokens(any)).thenAnswer((_) async => []); + mockTokenFolderRepository = MockTokenFolderRepository(); + when(mockTokenFolderRepository.loadFolders()).thenAnswer((_) async => []); + when(mockTokenFolderRepository.saveOrReplaceFolders(any)).thenAnswer((_) async => []); + }); + testWidgets('Rename and Delete Token', (tester) async { + await tester.pumpWidget(TestsAppWrapper( + overrides: [ + settingsProvider.overrideWith((ref) => SettingsNotifier(repository: mockSettingsRepository)), + tokenProvider.overrideWith((ref) => TokenNotifier(repository: mockTokenRepository)), + tokenFolderProvider.overrideWith((ref) => TokenFolderNotifier(repository: mockTokenFolderRepository)), + ], + child: PrivacyIDEAAuthenticator(customization: ApplicationCustomization.defaultCustomization), + )); + await _renameToken(tester, 'Renamed Token'); + await _renameToken(tester, 'Renamed Token Again'); + await _deleteToken(tester); + }, timeout: const Timeout(Duration(minutes: 5))); +} + +Future _renameToken(WidgetTester tester, String newName) async { + // Rename Token + await tester.pumpAndSettle(); + await pumpUntilFindNWidgets(tester, find.byType(HOTPTokenWidget), 1, const Duration(seconds: 10)); + expect(find.byType(HOTPTokenWidget), findsOneWidget); + await tester.drag(find.byType(HOTPTokenWidget), const Offset(-300, 0)); + await tester.pumpAndSettle(); + await pumpUntilFindNWidgets(tester, find.byType(EditHOTPTokenAction), 1, const Duration(seconds: 2)); + await tester.tap(find.byType(EditHOTPTokenAction)); + await tester.pumpAndSettle(); + expect(find.text('Edit Token'), findsOneWidget); + expect(find.byType(TextFormField), findsNWidgets(3)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, ''); + await tester.enterText(find.byType(TextFormField).first, newName); + await pumpUntilFindNWidgets(tester, find.widgetWithText(TextFormField, newName), 1, const Duration(seconds: 2)); + await tester.tap(find.text('Save')); + await pumpUntilFindNWidgets(tester, find.text(newName), 1, const Duration(seconds: 2)); + expect(find.text(newName), findsOneWidget); +} + +Future _deleteToken(WidgetTester tester) async { + await tester.pumpAndSettle(); + await pumpUntilFindNWidgets(tester, find.byType(HOTPTokenWidget), 1, const Duration(seconds: 10)); + expect(find.byType(HOTPTokenWidget), findsOneWidget); + await tester.drag(find.byType(HOTPTokenWidget), const Offset(-300, 0)); + await tester.pumpAndSettle(); + await pumpUntilFindNWidgets(tester, find.byType(EditHOTPTokenAction), 1, const Duration(seconds: 2)); + await tester.tap(find.byType(DefaultDeleteAction)); + await tester.pumpAndSettle(); + expect(find.text('Confirm deletion'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(find.byType(HOTPTokenWidget), findsNothing); +} diff --git a/integration_test/two_step_rollout_test.dart b/integration_test/two_step_rollout_test.dart new file mode 100644 index 000000000..cb0074b12 --- /dev/null +++ b/integration_test/two_step_rollout_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacyidea_authenticator/main_netknights.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; +import 'package:privacyidea_authenticator/state_notifiers/settings_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_folder_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_notifier.dart'; +import 'package:privacyidea_authenticator/utils/app_customizer.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view.dart'; +import 'package:privacyidea_authenticator/widgets/widget_keys.dart'; + +import '../test/tests_app_wrapper.dart'; +import '../test/tests_app_wrapper.mocks.dart'; + +/* + +// qr codes: +const String URI_TYPE = 'URI_TYPE'; +const String URI_LABEL = 'URI_LABEL'; +const String URI_ALGORITHM = 'URI_ALGORITHM'; +const String URI_DIGITS = 'URI_DIGITS'; +const String URI_SECRET = 'URI_SECRET'; +const String URI_COUNTER = 'URI_COUNTER'; +const String URI_PERIOD = 'URI_PERIOD'; +const String URI_ISSUER = 'URI_ISSUER'; +const String URI_PIN = 'URI_PIN'; +const String URI_IMAGE = 'URI_IMAGE'; + +// 2 step: +const String URI_SALT_LENGTH = 'URI_SALT_LENGTH'; +const String URI_OUTPUT_LENGTH_IN_BYTES = 'URI_OUTPUT_LENGTH_IN_BYTES'; +const String URI_ITERATIONS = 'URI_ITERATIONS'; + + */ +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late final MockSettingsRepository mockSettingsRepository; + late final MockTokenRepository mockTokenRepository; + late final MockTokenFolderRepository mockTokenFolderRepository; + setUp(() { + mockSettingsRepository = MockSettingsRepository(); + when(mockSettingsRepository.loadSettings()).thenAnswer((_) async => SettingsState(isFirstRun: false)); + when(mockSettingsRepository.saveSettings(any)).thenAnswer((_) async => true); + mockTokenRepository = MockTokenRepository(); + when(mockTokenRepository.loadTokens()).thenAnswer((_) async => []); + when(mockTokenRepository.saveOrReplaceTokens(any)).thenAnswer((_) async => []); + when(mockTokenRepository.deleteTokens(any)).thenAnswer((_) async => []); + mockTokenFolderRepository = MockTokenFolderRepository(); + when(mockTokenFolderRepository.loadFolders()).thenAnswer((_) async => []); + when(mockTokenFolderRepository.saveOrReplaceFolders(any)).thenAnswer((_) async => []); + }); + testWidgets( + '2step rollout test', + (tester) async { + await tester.pumpWidget(TestsAppWrapper( + overrides: [ + settingsProvider.overrideWith((ref) => SettingsNotifier(repository: mockSettingsRepository)), + tokenProvider.overrideWith((ref) => TokenNotifier(repository: mockTokenRepository)), + tokenFolderProvider.overrideWith((ref) => TokenFolderNotifier(repository: mockTokenFolderRepository)), + ], + child: PrivacyIDEAAuthenticator(customization: ApplicationCustomization.defaultCustomization), + )); + await _addTwoStepHotpTokenTest(tester); + await _addTwoStepTotpTokenTest(tester); + }, + timeout: const Timeout(Duration(minutes: 10)), + ); +} + +Future _addTwoStepHotpTokenTest(WidgetTester tester) async { + await pumpUntilFindNWidgets(tester, find.byType(MainView), 1, const Duration(seconds: 10)); + globalRef!.read(tokenProvider.notifier).addTokenFromOtpAuth( + otpAuth: + 'otpauth://hotp/OATH0001DBD0?secret=AALIBQJMOGEE7SAVEZ5D3K2ADO7MVFQD&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8&2step_output=20&2step_difficulty=10000'); + await pumpUntilFindNWidgets(tester, find.text('Phone part:'), 1, const Duration(seconds: 20)); + expect(find.text('Phone part:'), findsOneWidget); + final finder = find.byKey(twoStepDialogContent); + expect(finder, findsOneWidget); + final text = finder.evaluate().single.widget as Text; + final phonePart = text.data; + expect(phonePart, isNotNull); + expect(phonePart, isNotEmpty); + // step_output=20 + expect(phonePart!.replaceAll(' ', '').length, 20); + expect(find.text('Dismiss'), findsOneWidget); + await tester.tap(find.text('Dismiss')); + await tester.pumpAndSettle(); +} + +Future _addTwoStepTotpTokenTest(WidgetTester tester) async { + await pumpUntilFindNWidgets(tester, find.byType(MainView), 1, const Duration(seconds: 10)); + globalRef!.read(tokenProvider.notifier).addTokenFromOtpAuth( + otpAuth: + 'otpauth://totp/TOTP00009D5F?secret=NZ4OPONKAAGDFN2QHV26ZWYVTLFER4C6&period=30&digits=6&issuer=privacyIDEA&2step_salt=8&2step_output=20&2step_difficulty=10000'); + await pumpUntilFindNWidgets(tester, find.text('Phone part:'), 1, const Duration(seconds: 20)); + expect(find.text('Phone part:'), findsOneWidget); + final finder = find.byKey(twoStepDialogContent); + expect(finder, findsOneWidget); + final text = finder.evaluate().single.widget as Text; + final phonePart = text.data; + expect(phonePart, isNotNull); + expect(phonePart, isNotEmpty); + // step_output=20 + expect(phonePart!.replaceAll(' ', '').length, 20); + expect(find.text('Dismiss'), findsOneWidget); + await tester.tap(find.text('Dismiss')); + await tester.pumpAndSettle(); +} diff --git a/integration_test/views_test.dart b/integration_test/views_test.dart new file mode 100644 index 000000000..32aeb8f52 --- /dev/null +++ b/integration_test/views_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:pointycastle/asymmetric/api.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; +import 'package:privacyidea_authenticator/main_netknights.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; +import 'package:privacyidea_authenticator/state_notifiers/settings_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_folder_notifier.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_notifier.dart'; +import 'package:privacyidea_authenticator/utils/app_customizer.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart'; +import 'package:privacyidea_authenticator/views/settings_view/settings_view_widgets/settings_groups.dart'; + +import '../test/tests_app_wrapper.dart'; +import '../test/tests_app_wrapper.mocks.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late final MockSettingsRepository mockSettingsRepository; + late final MockTokenRepository mockTokenRepository; + late final MockTokenFolderRepository mockTokenFolderRepository; + late final MockRsaUtils mockRsaUtils; + late final MockFirebaseUtils mockFirebaseUtils; + late final MockPrivacyIdeaIOClient mockIOClient; + setUp(() { + mockSettingsRepository = MockSettingsRepository(); + when(mockSettingsRepository.loadSettings()).thenAnswer((_) async => SettingsState( + isFirstRun: false, + localePreference: AppLocalizations.supportedLocales.firstWhere((language) => language.toLanguageTag().substring(0, 2) == 'en'), + useSystemLocale: false)); + when(mockSettingsRepository.saveSettings(any)).thenAnswer((_) async => true); + mockTokenRepository = MockTokenRepository(); + when(mockTokenRepository.loadTokens()).thenAnswer((_) async => []); + when(mockTokenRepository.saveOrReplaceTokens(any)).thenAnswer((_) async => []); + when(mockTokenRepository.deleteTokens(any)).thenAnswer((_) async => []); + mockTokenFolderRepository = MockTokenFolderRepository(); + when(mockTokenFolderRepository.loadFolders()).thenAnswer((_) async => []); + when(mockTokenFolderRepository.saveOrReplaceFolders(any)).thenAnswer((_) async => []); + mockRsaUtils = MockRsaUtils(); + when(mockRsaUtils.serializeRSAPublicKeyPKCS8(any)).thenAnswer((_) => 'publicKey'); + when(mockRsaUtils.generateRSAKeyPair()).thenAnswer((_) => const RsaUtils() + .generateRSAKeyPair()); // We get here a random result anyway and is it more likely to make errors by mocking it than by using the real method + mockFirebaseUtils = MockFirebaseUtils(); + when(mockFirebaseUtils.getFBToken()).thenAnswer((_) => Future.value('fbToken')); + when(mockRsaUtils.deserializeRSAPublicKeyPKCS1('publicKey')).thenAnswer((_) => RSAPublicKey(BigInt.one, BigInt.one)); + mockIOClient = MockPrivacyIdeaIOClient(); + when(mockIOClient.doPost( + url: anyNamed('url'), + body: anyNamed('body'), + sslVerify: anyNamed('sslVerify'), + )).thenAnswer((_) => Future.value(Response('{"detail": {"public_key": "publicKey"}}', 200))); + }); + + testWidgets('Views Test', (tester) async { + await tester.pumpWidget(TestsAppWrapper( + overrides: [ + settingsProvider.overrideWith((ref) => SettingsNotifier(repository: mockSettingsRepository)), + tokenProvider.overrideWith((ref) => TokenNotifier( + repository: mockTokenRepository, + rsaUtils: mockRsaUtils, + firebaseUtils: mockFirebaseUtils, + ioClient: mockIOClient, + )), + tokenFolderProvider.overrideWith((ref) => TokenFolderNotifier(repository: mockTokenFolderRepository)), + ], + child: PrivacyIDEAAuthenticator(customization: ApplicationCustomization.defaultCustomization), + )); + + await _licensesViewTest(tester); + await _popUntilMainView(tester); + await _settingsViewTest(tester); + }, timeout: const Timeout(Duration(minutes: 10))); +} + +Future _popUntilMainView(WidgetTester tester) async { + await pumpUntilFindNWidgets(tester, find.byIcon(Icons.arrow_back), 1, const Duration(seconds: 2)); + while (find.byIcon(Icons.arrow_back).evaluate().isNotEmpty) { + await tester.tap(find.byIcon(Icons.arrow_back)); + await pumpUntilFindNWidgets(tester, find.byIcon(Icons.arrow_back), 1, const Duration(seconds: 2)); + } + return; +} + +Future _licensesViewTest(WidgetTester tester) async { + await pumpUntilFindNWidgets(tester, find.byIcon(Icons.info_outline), 1, const Duration(seconds: 10)); + await tester.tap(find.byIcon(Icons.info_outline)); + await tester.pumpAndSettle(); + expect(find.text(ApplicationCustomization.defaultCustomization.appName), findsOneWidget); + expect(find.text(ApplicationCustomization.defaultCustomization.websiteLink), findsOneWidget); + await tester.pumpAndSettle(); + expect(find.text('Licenses'), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); + expect(find.byType(LicensePage), findsOneWidget); +} + +Future _settingsViewTest(WidgetTester tester) async { + await pumpUntilFindNWidgets(tester, find.byIcon(Icons.settings), 1, const Duration(seconds: 10)); + await tester.tap(find.byIcon(Icons.settings)); + await tester.pumpAndSettle(); + expect(find.text('Settings'), findsOneWidget); + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Language'), findsOneWidget); + expect(find.text('Error logs'), findsOneWidget); + expect(find.byType(SettingsGroup), findsNWidgets(3)); + globalRef!.read(tokenProvider.notifier).addTokenFromOtpAuth( + otpAuth: + 'otpauth://pipush/label?url=http%3A%2F%2Fwww.example.com&ttl=10&issuer=issuer&enrollment_credential=enrollmentCredentials&v=1&serial=serial&serial=serial&sslverify=0'); + await pumpUntilFindNWidgets(tester, find.text('Push Token'), 1, const Duration(minutes: 5)); + expect(find.text('Push Token'), findsOneWidget); + expect(find.byType(SettingsGroup), findsNWidgets(4)); +} diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 399e9340e..2f2c1098a 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1,2 +1,3 @@ #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile index 876998e93..ad8656662 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -38,7 +38,7 @@ post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| - config.build_settings['ENABLE_BITCODE'] = 'YES' + config.build_settings['ENABLE_BITCODE'] = 'NO' config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' # You can remove unused permissions here # for more infomation: https://github.com/BaseflowIT/flutter-permission-handler/blob/master/permission_handler/ios/Classes/PermissionHandlerEnums.h diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a03e82a17..12948c9ec 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -275,6 +275,7 @@ "${BUILT_PRODUCTS_DIR}/flutter_mailer/flutter_mailer.framework", "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", "${BUILT_PRODUCTS_DIR}/fluttertoast/fluttertoast.framework", + "${BUILT_PRODUCTS_DIR}/integration_test/integration_test.framework", "${BUILT_PRODUCTS_DIR}/local_auth_ios/local_auth_ios.framework", "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", "${BUILT_PRODUCTS_DIR}/native_device_orientation/native_device_orientation.framework", @@ -305,6 +306,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_mailer.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/fluttertoast.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/integration_test.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/native_device_orientation.framework", @@ -440,6 +442,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 627QALYL3B; ENABLE_BITCODE = NO; + FLUTTER_TARGET = lib/main_netknights.dart; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -447,7 +450,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "privacyIDEA Authenticator"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -582,6 +585,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 627QALYL3B; ENABLE_BITCODE = NO; + FLUTTER_TARGET = lib/main_netknights.dart; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -589,7 +593,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "privacyIDEA Authenticator"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -618,6 +622,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 627QALYL3B; ENABLE_BITCODE = NO; + FLUTTER_TARGET = lib/main_netknights.dart; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", @@ -625,7 +630,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "privacyIDEA Authenticator"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/l10n.yaml b/l10n.yaml index 6e4b19b07..5f286e436 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -1,4 +1,5 @@ arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart -untranslated-messages-file: lib/l10n/untranslated.txt \ No newline at end of file +untranslated-messages-file: lib/l10n/untranslated.txt +synthetic-package: false \ No newline at end of file diff --git a/lib/repo/settings_repository.dart b/lib/interfaces/repo/settings_repository.dart similarity index 72% rename from lib/repo/settings_repository.dart rename to lib/interfaces/repo/settings_repository.dart index e092be59b..0538e84fc 100644 --- a/lib/repo/settings_repository.dart +++ b/lib/interfaces/repo/settings_repository.dart @@ -1,4 +1,4 @@ -import '../model/states/settings_state.dart'; +import '../../model/states/settings_state.dart'; abstract class SettingsRepository { Future saveSettings(SettingsState settings); diff --git a/lib/interfaces/repo/token_folder_repository.dart b/lib/interfaces/repo/token_folder_repository.dart new file mode 100644 index 000000000..9a6f4055c --- /dev/null +++ b/lib/interfaces/repo/token_folder_repository.dart @@ -0,0 +1,6 @@ +import '../../model/token_folder.dart'; + +abstract class TokenFolderRepository { + Future> saveOrReplaceFolders(List folders); + Future> loadFolders(); +} diff --git a/lib/interfaces/repo/token_repository.dart b/lib/interfaces/repo/token_repository.dart new file mode 100644 index 000000000..40951a61b --- /dev/null +++ b/lib/interfaces/repo/token_repository.dart @@ -0,0 +1,7 @@ +import '../../model/tokens/token.dart'; + +abstract class TokenRepository { + Future> saveOrReplaceTokens(List tokens); + Future> loadTokens(); + Future> deleteTokens(List tokens); +} diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 5b7613374..84b48504a 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -2,141 +2,95 @@ "@@last_modified": "2023-08-07", "guide": "Průvodce", "@guide": { - "description": "Button to open the guide screen.", - "type": "text", - "placeholders": {} + "description": "Button to open the guide screen." }, "retry": "Zkusit znovu", "@retry": { - "description": "Label for e.g. a button. Something is tried to be done again.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something is tried to be done again." }, "accept": "Přijmout", "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets accepted by the user." }, "decline": "Odmítnout", "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets declined by the user." }, "name": "Název", "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens name should be entered." }, "secret": "Heslo", "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens secret should be entered." }, "encoding": "Kódování", "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "algorithm": "Algoritmus", "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "digits": "Počet číslic", "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the number of digits for the opt value is selected." }, "type": "Typ", "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the type of the token is selected." }, "period": "Časový interval", "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the period of the totp token is selected." }, "rename": "Přejmenovat", "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes renaming the token." }, "cancel": "Zrušit", "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} + "description": "Button to cancel an action." }, "delete": "Smazat", "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes deleting the token." }, "dismiss": "Zavřít", "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} + "description": "Text of a button that closes a dialog." }, "addToken": "Přidat token", "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} + "description": "The button to open the screen to add tokens by hand." }, "scanQrCode": "Naskenovat QR kód", "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} + "description": "The button to scan otpauth qr-codes." }, "enterDetailsForToken": "Vložte podrobnosti tokenu", "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." }, "pleaseEnterANameForThisToken": "Vložte název pro tento token.", "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a name for a token." }, "pleaseEnterASecretForThisToken": "Vložte tajný klíč pro tento token.", "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a secret for a token." }, "theSecretDoesNotFitTheCurrentEncoding": "Tajný klíč neodpovídá zvolenému kódování.", "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user that the secret does not fit the selected encoding." }, "renameToken": "Přejmenovat token", "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token can be entered." }, "confirmDeletion": "Potvrdit smazání", "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a token can be deleted." }, "confirmDeletionOf": "Opravdu chcete smazat token {name}?", "@confirmDeletionOf": { @@ -150,15 +104,11 @@ }, "generatingPhonePart": "Generování klientské části", "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone part gets generated right now." }, "phonePart": "Klientská část:", "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." }, "otpValueCopiedMessage": "Heslo \"{otpValue}\" bylo zkopírováno do schránky.", "@otpValueCopiedMessage": { @@ -172,93 +122,63 @@ }, "settings": "Nastavení", "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} + "description": "Button to open the settings page." }, "pushToken": "Push notifikace", "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} + "description": "Title for the settings block concerning the push tokens." }, "theme": "Vzhled", "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} + "description": "Title of the setting group where the theme can be selected." }, "lightTheme": "Světlý", "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} + "description": "The light theme." }, "darkTheme": "Tmavý", "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} + "description": "The dark theme." }, "systemTheme": "Použít nastavení systému", "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} + "description": "The systems theme." }, "enablePolling": "Povolit polling", "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} + "description": "Name of the setting switch that enables polling." }, "requestPushChallengesPeriodically": "Periodicky získávat výzvy ze serveru. Povolte pokud nefunguje příjem push notifikací.", "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} + "description": "The description of the polling feature." }, "synchronizePushTokens": "Synchronizace push tokenů", "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Title of synchronizing push tokens in settings." }, "synchronizesTokensWithServer": "Synchronizovat tokeny se serverem privacyIDEA.", "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Description of synchronizing push tokens in settings." }, "sync": "Synchronizovat", "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} + "description": "Text of button that is used to synchronize push tokens." }, "synchronizingTokens": "Tokeny se synchronizují.", "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} + "description": "Title of the push synchronization dialog." }, "allTokensSynchronized": "Všechny tokeny jsou synchronizované.", "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." }, "synchronizationFailed": "Synchronizace následujících tokenů selhala, zkuste to znovu:", "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} + "description": "Headline for the list of tokens where the synchronization failed." }, "tokensDoNotSupportSynchronization": "Následující tokeny nepodporují synchronizaci a musí být znovu zaregistrovány:", "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." }, "errorRollOutFailed": "Registrace tokenu {name} selhala. Kód chyby: {errorCode}", "@errorRollOutFailed": { @@ -275,9 +195,7 @@ }, "errorSynchronizationNoNetworkConnection": "Synchronizace tokenů selhala, připojení k serveru privacyIDEA se nezdařilo.", "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." }, "errorRollOutUnknownError": "Vyskytla se neznámá chyba. Registrace není možná: {e}", "@errorRollOutUnknownError": { @@ -291,362 +209,190 @@ }, "rollingOut": "Registrace", "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} + "description": "Label that tells the user that the token is being rolled out." }, "pollingChallenges": "Čekám na nové požadavky", "@pollingChallenges": { - "type": "text", - "placeholders": {} + "type": "text" }, "unexpectedError": "Nastala neočekávaná chyba.", "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} + "description": "Title of page report mode." }, "pollingFailNoNetworkConnection": "Stahování selhalo. Server není dostupný.", "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because no network connection is available." }, "useDeviceLocaleTitle": "Použít jazyk zařízení", "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Title of the switch tile where using the devices language can be enabled." }, "useDeviceLocaleDescription": "Použít jazyk zařízení, pokud je podporován, případně angličtinu.", "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Description of the switch tile where using the devices language can be enabled." }, "language": "Jazyk", "@language": { - "description": "Title of language setting group.", - "type": "text", - "placeholders": {} + "description": "Title of language setting group." }, "authenticateToShowOtp": "Pro zobrazení jednorázového kódu se přihlaste.", "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to view a one time password." }, "authenticateToUnLockToken": "Pro změnu uzamčení tokenu se přihlaste.", "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to lock or unlock a token." }, "biometricRequiredTitle": "Biometrické ověření není nastaveno", "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, "biometricHint": "Vyžadováno přihlášení", "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, "biometricNotRecognized": "Ověření se nezdařilo. Zkuste to znovu.", "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, "biometricSuccess": "Přihlášení bylo úspěšné", "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsRequiredTitle": "Není nastaven zámek zařízení", "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsSetupDescription": "Nastave zámek zařízení v nastavení zařízení", "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, "signInTitle": "Vyžadováno přihlášení", "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, "goToSettingsButton": "Otevřít nastavení", "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, "goToSettingsDescription": "Není nastaveno přihlášení zámkem zařízení ani biometrické ověření. Aktivujte je v nastavení zařízení.", "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." }, "lockOut": "Biometrické ověření je deaktivováno. Pro aktivaci zamkněte a znovu odemkněte obrazovku/zařízení.", "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, "authNotSupportedTitle": "Vyžadován zámek zařízení nebo biometrické ověření", "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." }, "authNotSupportedBody": "Tato akce vyžaduje, aby bylo zařízení chráněno zámkem zařízení nebo biometrickým ověřením.", "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." }, "lock": "Zamknout", "@lock": { - "description": "Description of button that locks a token.", - "type": "text" + "description": "Description of button that locks a token." }, "unlock": "Odemknout", "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" + "description": "Description of button that unlocks a token." }, "noResultTitle": "Nejsou nainstalovány žádné tokeny.", "@noResultTitle": { - "description": "No tokens installed yet.", - "type": "text" + "description": "No tokens installed yet." }, "noResultText1": "stiskněte tlačítko ", "@noResultText1": { - "description": "first noresult text", - "type": "text" + "description": "first noresult text" }, "noResultText2": " a začněte s používáním.", "@noResultText2": { - "description": "second noresult text", - "type": "text" + "description": "second noresult text" }, "onBoardingTitle1": "{appName}", "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", "placeholders": { "appName": { - "type": "String", "example": "privacyIDEA Authenticator" } } }, "onBoardingText1": "vícefázové ověření\nusnadněno", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, "onBoardingTitle2": "Maximální Bezpečnost", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, "onBoardingText2": "Uložte tokeny do svého zařízení\nchráněné biometrickým ověřením", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, "onBoardingTitle3": "Navštivte náš profil Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, "onBoardingText3": "Tuto aplikaci má open source", - "@onBoardingText3": { - "description": "onBoardingTitle3", - "type": "text" - }, "errorLogTitle": "Protokoly o chybách", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, "sendErrorHint": "Pošlete nám protokol o chybě e-mailem", "@sendErrorHint": { - "description": "Hint for the user about what he will send.", - "type": "text" + "description": "Hint for the user about what he will send." }, "enableVerboseLogging": "Povolit slovní protokolování", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, "clearErrorLogHint": "Vymaže místní soubor protokolu chyb", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, "logMenu": "Nabídka protokolu", - "@logMenu": { - "description": "logMenu", - "type": "text" - }, "sendErrorDialogHeader": "Odeslat e-mailem", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, "noLogToSend": "Je třeba odeslat protokol.", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "Přiložen je soubor protokolu o chybách.", - "@errorLogFileAttached": { - "description": "Message for email body", - "type": "text" + "errorMailBody": "Přiložen je soubor protokolu o chybách.\nTento text můžete nahradit dalšími informacemi o chybě.", + "@errorMailBody": { + "description": "Message for email body" }, "errorLogCleared": "Protokoly chyb byly vymazány.", - "@errorLogCleared": { - "description": "errorLogCleared", - "type": "text" - }, "showDetails": "Zobrazit podrobnosti", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, "open": "Otevřít", - "@open": { - "description": "open", - "type": "text" - }, "sendErrorDialogBody": "V aplikaci se vyskytla neznámá chyba. Informace uvedené níže mohou být odeslány vývojářům e-mailem pro vyřešení chyby v budoucnu.", "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" + "description": "Description shown to the user about what info the error report contains." }, "noFbToken": "Není k dispozici žádný token Firebase.", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, "firebaseToken": "Token Firebase", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, "noPublicKey": "Není k dispozici žádný veřejný klíč.", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, "publicKey": "Veřejný klíč", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, "editToken": "Upravit token", - "@editToken": { - "description": "editToken", - "type": "text" - }, "edit": "Upravit", - "@edit": { - "description": "edit", - "type": "text" - }, "save": "Uložit", - "@save": { - "description": "save", - "type": "text" - }, "validFor": "Platné pro", - "@validFor": { - "description": "validFor", - "type": "text" - }, "validUntil": "Platné do", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, "deleteLockedToken": "Prosím, autentifikujte se pro smazání uzamčeného tokenu.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, "editLockedToken": "Prosím, autentifikujte se pro úpravu uzamčeného tokenu.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, "uncollapseLockedFolder": "Chcete-li otevřít uzamčenou složku, ověřte se.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, "renameTokenFolder": "Přejmenování složky", - "@renameTokenFolder": { - "description": "renameTokenFolder", - "type": "text" - }, "addANewFolder": "Vytvoření nové složky", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, "folderName": "Název složky", - "@folderName": { - "description": "folderName", - "type": "text" - }, "retryRollout": "Zkusit znovu", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, "generatingRSAKeyPair": "Generování párů klíčů RSA", "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "generatingRSAKeyPairFailed": "Generování páru klíčů RSA se nezdařilo", "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKey": "Odeslání veřejného klíče RSA", "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKeyFailed": "Nepodařilo se odeslat veřejný klíč RSA", "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponse": "Rozbor odpovědi", "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponseFailed": "Parsování odpovědi se nezdařilo", "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "rolloutCompleted": "Zavedení dokončeno", "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "errorRollOutNoConnectionToServer": "Registrace tokenu {name} selhala. Server není dostupný.", "@errorRollOutNoConnectionToServer": { "description": "Message for the rollout process", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -654,45 +400,20 @@ } }, "authToAcceptPushRequest": "Pro přijetí požadavku na push notifikaci se přihlaste.", - "@authToAcceptPushRequest": { - "description": "authToAcceptPushRequest", - "type": "text" - }, "authToDeclinePushRequest": "Pro odmítnutí požadavku na push notifikaci se přihlaste.", - "@authToDeclinePushRequest": { - "description": "authToDeclinePushRequest", - "type": "text" - }, "incomingAuthRequestError": "Zpráva neposkytla potřebná data nebo byla data chybně formulována.", - "@incomingAuthRequestError": { - "description": "incomingAuthRequestError", - "type": "text" - }, "imageUrl": "URL obrázku", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, "errorRollOutSSLHandshakeFailed": "SSL handshake se nezdařil. Roll-out není možný.", - "@errorRollOutSSLHandshakeFailed": { - "description": "errorRollOutSSLHandshakeFailed", - "type": "text" - }, "errorWhenPullingChallenges": "Při dotazování na výzvy {name} došlo k chybě.", "@errorWhenPullingChallenges": { - "description": "errorWhenPullingChallenges", - "type": "text", "placeholders": { "name": { - "type": "String", "example": "PUSH1234A" } } }, "errorRollOutTokenExpired": "Roll-out tohoto tokenu již není možný.\nPlatnost tokenu {name} vypršela.", "@errorRollOutTokenExpired": { - "description": "errorRollOutTokenExpired", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -700,28 +421,21 @@ } }, "yes": "Ano", - "@yes": { - "description": "yes", - "type": "text" - }, "no": "Ne", - "@no": { - "description": "no", - "type": "text" - }, "butDiscardIt": "ale zahodit jej", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, "declineIt": "odmítnout jej ", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, "requestTriggerdByUserQuestion": "Byl tento požadavek vyvolán vámi?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } + "grantCameraPermissionDialogTitle": "Camera permission is not granted", + "grantCameraPermissionDialogContent": "Please grant camera permission to scan QR codes.", + "grantCameraPermissionDialogPermanentlyDenied": "Oprávnění kamery je trvale odepřeno. Udělte prosím oprávnění fotoaparátu v nastavení telefonu.", + "grantCameraPermissionDialogButton": "Udělit oprávnění", + "decryptErrorTitle": "Chyba dešifrování", + "decryptErrorContent": "Bohužel se aplikaci nepodařilo dešifrovat vaše tokeny. To znamená, že šifrovací klíč je poškozen. Můžete to zkusit znovu nebo odstranit data aplikace, čímž by došlo k odstranění tokenů v aplikaci.", + "decryptErrorButtonDelete": "Odstranit", + "decryptErrorButtonSendError": "Odeslat chybu", + "decryptErrorButtonRetry": "Opakování", + "decryptErrorDeleteConfirmationContent": "Jste si jisti, že chcete data aplikace odstranit?", + "hidePushTokens": "Skrýt push tokeny", + "hidePushTokensDescription": "Skrýt push tokeny ze seznamu tokenů. Tím se tokeny neodstraní a budou stále viditelné na samostatné obrazovce.", + "licensesAndVersion": "Licence a verze" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a7b5350cf..c837d1083 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -2,146 +2,99 @@ "@@last_modified": "2023-08-07", "guide": "Anleitung", "@guide": { - "description": "Button to open the guide screen.", - "type": "text", - "placeholders": {} + "description": "Button to open the guide screen." }, "retry": "Erneut versuchen", "@retry": { - "description": "Label for e.g. a button. Something is tried to be done again.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something is tried to be done again." }, "accept": "Akzeptieren", "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets accepted by the user." }, "decline": "Ablehnen", "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets declined by the user." }, "name": "Name", "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens name should be entered." }, "secret": "Geheimnis", "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens secret should be entered." }, "encoding": "Kodierung", "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "algorithm": "Algorithmus", "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "digits": "Ziffern", "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the number of digits for the opt value is selected." }, "type": "Art", "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the type of the token is selected." }, "period": "Periode", "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the period of the totp token is selected." }, "rename": "Umbenennen", "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes renaming the token." }, "cancel": "Abbrechen", "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} + "description": "Button to cancel an action." }, "delete": "Löschen", "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes deleting the token." }, "dismiss": "Schließen", "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} + "description": "Text of a button that closes a dialog." }, "addToken": "Token hinzufügen", "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} + "description": "The button to open the screen to add tokens by hand." }, "scanQrCode": "QR-Code scannen", "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} + "description": "The button to scan otpauth qr-codes." }, "enterDetailsForToken": "Neuen Token konfigurieren", "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." }, "pleaseEnterANameForThisToken": "Bitte geben Sie einen Namen ein.", "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a name for a token." }, "pleaseEnterASecretForThisToken": "Bitte geben Sie ein Geheimnis ein.", "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a secret for a token." }, "theSecretDoesNotFitTheCurrentEncoding": "Das Geheimnis entspricht nicht der gewählten Verschlüsselung.", "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user that the secret does not fit the selected encoding." }, "renameToken": "Token umbenennen", "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token can be entered." }, "confirmDeletion": "Löschen bestätigen", "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a token can be deleted." }, "confirmDeletionOf": "Bestätigen Sie das Löschen von {name}?", "@confirmDeletionOf": { "description": "Asks for confirmation on deleting a token.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234" @@ -150,20 +103,15 @@ }, "generatingPhonePart": "Generiere Telefonanteil", "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone part gets generated right now." }, "phonePart": "Telefonanteil:", "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." }, "otpValueCopiedMessage": "Passwort \"{otpValue}\" wurde in Zwischenablage kopiert.", "@otpValueCopiedMessage": { "description": "Tells the user that the otp value was copied to the clipboard.", - "type": "text", "placeholders": { "otpValue": { "example": "055374" @@ -172,98 +120,67 @@ }, "settings": "Einstellungen", "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} + "description": "Button to open the settings page." }, "pushToken": "Push Token", "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} + "description": "Title for the settings block concerning the push tokens." }, "theme": "Farbschema", "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} + "description": "Title of the setting group where the theme can be selected." }, "lightTheme": "Hell", "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} + "description": "The light theme." }, "darkTheme": "Dunkel", "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} + "description": "The dark theme." }, "systemTheme": "Nutze Farbschema des Geräts", "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} + "description": "The systems theme." }, "enablePolling": "Aktives Stellen von Push-Anfragen", "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} + "description": "Name of the setting switch that enables polling." }, "requestPushChallengesPeriodically": "Fordert regelmäßig Push-Anfragen vom Server an. Aktivieren Sie diese Funktion, wenn Nachrichten ansonsten nicht erhalten werden.", "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} + "description": "The description of the polling feature." }, "synchronizePushTokens": "Synchronisiere Push Token", "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Title of synchronizing push tokens in settings." }, "synchronizesTokensWithServer": "Synchronisiert Token mit dem privacyIDEA Server.", "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Description of synchronizing push tokens in settings." }, "sync": "Sync", "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} + "description": "Text of button that is used to synchronize push tokens." }, "synchronizingTokens": "Synchronisiere Token.", "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} + "description": "Title of the push synchronization dialog." }, "allTokensSynchronized": "Alle Token wurden synchronisiert.", "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." }, "synchronizationFailed": "Synchronisation ist für die folgenden Token fehlgeschlagen:", "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} + "description": "Headline for the list of tokens where the synchronization failed." }, "tokensDoNotSupportSynchronization": "Die folgenden Token unterstützen keine Synchronisation und müssen erneut ausgerollt werden:", "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." }, "errorRollOutFailed": "Ausrollen von {name} ist fehlgeschlagen. Fehlercode: {errorCode}", "@errorRollOutFailed": { "description": "Tells the user that the token could not be rolled out, because a network error occurred.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -275,14 +192,11 @@ }, "errorSynchronizationNoNetworkConnection": "Die Synchronisation ist fehlgeschlagen, da der privacyIDEA Server nicht erreicht werden konnte.", "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." }, "errorRollOutUnknownError": "Ein unbekannter Fehler ist aufgetreten. Aurollen nicht möglich: {e}", "@errorRollOutUnknownError": { "description": "Tells the user that the roll-out failed because of an unknown error.", - "type": "text", "placeholders": { "e": { "example": "IllegalArgumentException on Line 5 ..." @@ -291,403 +205,194 @@ }, "rollingOut": "Ausrollen", "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} + "description": "Label that tells the user that the token is being rolled out." }, "pollingChallenges": "Frage ausstehende Authentifizierungsanfragen ab", - "@pollingChallenges": { - "type": "text", - "placeholders": {} - }, "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten.", "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} + "description": "Title of page report mode." }, "pollingFailNoNetworkConnection": "Abfrage fehlgeschlagen, der Server ist nicht erreichbar.", "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because no network connection is available." }, "useDeviceLocaleTitle": "Nutze Systemsprache", "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Title of the switch tile where using the devices language can be enabled." }, "useDeviceLocaleDescription": "Nutze Systemsprache, falls diese unterstützt wird. Anderenfalls nutze Englisch. ", "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Description of the switch tile where using the devices language can be enabled." }, "language": "Sprache", "@language": { - "description": "Title of language setting group.", - "type": "text", - "placeholders": {} + "description": "Title of language setting group." }, "authenticateToShowOtp": "Bitte authentifizieren Sie sich, um das Einmalpasswort anzuzeigen.", "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to view a one time password." }, "authenticateToUnLockToken": "Bitte authentifizieren Sie sich, um den Sperrstatus des Tokens zu ändern.", "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to lock or unlock a token." }, "biometricRequiredTitle": "Biometrie ist nicht eingerichtet", "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, "biometricHint": "Authentifizierung wird benötigt", "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, "biometricNotRecognized": "Biometrie wurde nicht erkannt, bitte versuchen Sie es erneut", "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, "biometricSuccess": "Authentifizierung erfolgreich", "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsRequiredTitle": "Gerätepasswort ist nicht eingerichtet", "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsSetupDescription": "Setzen Sie bitte ein Gerätepasswort in den Einstellungen", "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, "signInTitle": "Authentifizierung wird benötigt", "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, "goToSettingsButton": "Gehe zu Einstellungen", "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, "goToSettingsDescription": "Authentifizierung durch Gerätepasswort oder Biometrie ist nicht eingerichtet. Bitte aktivieren Sie dies in den Geräteeinstellungen.", "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." }, "lockOut": "Biometrie ist deaktiviert. Bitte sperren und entsperren Sie Ihren Bildschirm um diese zu aktivieren.", "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, "authNotSupportedTitle": "Gerätepasswort oder Biometrie wird benötigt", "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." }, "authNotSupportedBody": "Diese Aktion erfordert, dass auf dem Gerät ein Passwort oder Biometrie eingerichtet ist.", "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." }, "lock": "Sperren", "@lock": { - "description": "Description of button that locks a token.", - "type": "text" + "description": "Description of button that locks a token." }, "unlock": "Entsperren", "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" + "description": "Description of button that unlocks a token." }, "noResultTitle": "Keine Token vorhanden.", "@noResultTitle": { - "description": "No tokens installed yet.", - "type": "text" + "description": "No tokens installed yet." }, "noResultText1": "Tippe auf das ", "@noResultText1": { - "description": "first no result text", - "type": "text" + "description": "first no result text" }, "noResultText2": " Icon um loszulegen!", "@noResultText2": { - "description": "second no result text", - "type": "text" + "description": "second no result text" }, "onBoardingTitle1": "{appName}", "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", "placeholders": { "appName": { - "type": "String", "example": "privacyIDEA Authenticator" } } }, "onBoardingText1": "Zwei-Faktor-Authentifizierung\neinfach gemacht", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, "onBoardingTitle2": "Maximale Sicherheit", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, "onBoardingText2": "Speichern Sie Ihre Token sicher auf diesem Gerät\nGeschützt durch Ihre biometrischen Daten", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, "onBoardingTitle3": "Besuchen Sie uns auf Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, "onBoardingText3": "Diese App ist Open Source", - "@onBoardingText3": { - "description": "onBoardingTitle3", - "type": "text" - }, "errorLogTitle": "Fehlerprotokolle", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, "sendErrorHint": "Senden Sie uns das Fehlerprotokoll per E-Mail", "@sendErrorHint": { - "description": "Hint for the user about what he will send.", - "type": "text" + "description": "Hint for the user about what he will send." }, "enableVerboseLogging": "Fehler ausführlich protokollieren", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, "clearErrorLogHint": "Löscht die lokale Fehlerprotokolldatei", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, "logMenu": "Protokollmenu", - "@logMenu": { - "description": "logMenu", - "type": "text" - }, "sendErrorDialogHeader": "Per E-Mail senden", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, "noLogToSend": "Es gibt kein Protokoll zu senden.", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "Die Fehlerprotokolldatei ist angehängt.", - "@errorLogFileAttached": { - "description": "Message for email body", - "type": "text" + "errorMailBody": "Die Fehlerprotokolldatei ist angehängt.\nSie können diesen Text durch zusätzliche Informationen über den Fehler ersetzen.", + "@errorMailBody": { + "description": "Message for email body" }, "errorLogCleared": "Fehlerprotokolle gelöscht", - "@errorLogCleared": { - "description": "errorLogCleared", - "type": "text" - }, "showDetails": "Details anzeigen", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, "open": "Öffnen", - "@open": { - "description": "open", - "type": "text" - }, "sendErrorDialogBody": "Ein unbekannter Fehler ist aufgetreten. Die unten gezeigten Informationen können den Entwicklern per E-Mail zugesendet werden, um zu helfen, diesen Fehler in Zukunft zu vermeiden.", "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" + "description": "Description shown to the user about what info the error report contains." }, "noFbToken": "Kein Firebase Token vorhanden", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, "firebaseToken": "Firebase Token", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, "noPublicKey": "Kein öffentlicher Schlüssel vorhanden", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, "publicKey": "Öffentlicher Schlüssel", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, "editToken": "Token bearbeiten", - "@editToken": { - "description": "editToken", - "type": "text" - }, "edit": "Bearbeiten", - "@edit": { - "description": "edit", - "type": "text" - }, "save": "Speichern", - "@save": { - "description": "save", - "type": "text" - }, "validFor": "Gültig für", - "@validFor": { - "description": "validFor", - "type": "text" - }, "validUntil": "Gültig bis", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, "deleteLockedToken": "Bitte authentifizieren Sie sich, um den gesperrten Token zu löschen.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, "editLockedToken": "Bitte authentifizieren Sie sich, um den gesperrten Token zu bearbeiten.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, "uncollapseLockedFolder": "Bitte authentifizieren Sie sich, um den gesperrten Ordner zu öffnen.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, "renameTokenFolder": "Ordner umbenennen", - "@renameTokenFolder": { - "description": "renameTokenFolder", - "type": "text" - }, "addANewFolder": "Neuen Ordner anlegen", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, "folderName": "Ordnername", - "@folderName": { - "description": "folderName", - "type": "text" - }, "retryRollout": "Erneut ausrollen", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, "generatingRSAKeyPair": "Generiere RSA Schlüsselpaar", "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "generatingRSAKeyPairFailed": "Generieren des RSA Schlüsselpaars fehlgeschlagen", "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKey": "Sende öffentlichen RSA Schlüssel", "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKeyFailed": "Senden des öffentlichen RSA Schlüssels fehlgeschlagen", "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponse": "Analysiere Antwort", "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponseFailed": "Analysieren der Antwort fehlgeschlagen", - "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" - }, "rolloutCompleted": "Ausrollen abgeschlossen", - "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" - }, "imageUrl": "Bild URL", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, "errorWhenPullingChallenges": "Fehler beim Abrufen der Authentifizierungsanfragen von {name}", "@errorWhenPullingChallenges": { - "description": "errorWhenPullingChallenges", - "type": "text", "placeholders": { "name": { - "type": "String", "example": "PUSH1234A" } } }, "errorRollOutNoConnectionToServer": "Der Rollout von Token {name} ist fehlgeschlagen, der Server konnte nicht erreicht werden.", - "@errorRollOutNoConnectionToServer": { - "description": "errorRollOutNoConnectionToServer", - "type": "text" - }, "authToAcceptPushRequest": "Bitte authentifizieren Sie sich, um die Anfrage anzunehmen.", - "@authToAcceptPushRequest": { - "description": "authToAcceptPushRequest", - "type": "text" - }, "authToDeclinePushRequest": "Bitte authentifizieren Sie sich, um die Anfrage abzulehnen.", - "@authToDeclinePushRequest": { - "description": "authToDeclinePushRequest", - "type": "text" - }, "incomingAuthRequestError": "Die Nachricht enthielt nicht die erforderlichen Daten oder die Daten waren falsch formatiert.", - "@incomingAuthRequestError": { - "description": "incomingAuthRequestError", - "type": "text" - }, "errorRollOutSSLHandshakeFailed": "SSL-Handshake fehlgeschlagen. Roll-out nicht möglich.", - "@errorRollOutSSLHandshakeFailed": { - "description": "errorRollOutSSLHandshakeFailed", - "type": "text" - }, "errorRollOutTokenExpired": "Das Ausrollen dieses Tokens ist nicht mehr möglich.\nDer Token {name} ist abgelaufen.", "@errorRollOutTokenExpired": { - "description": "errorRollOutTokenExpired", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -695,28 +400,21 @@ } }, "yes": "Ja", - "@yes": { - "description": "yes", - "type": "text" - }, "no": "Nein", - "@no": { - "description": "no", - "type": "text" - }, "butDiscardIt": "aber verwerfen", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, "declineIt": "ablehnen", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, "requestTriggerdByUserQuestion": "Wurde diese Anfrage von Ihnen ausgelöst?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } + "grantCameraPermissionDialogTitle": "Kamera-Berechtigung erforderlich", + "grantCameraPermissionDialogContent": "Um QR-Codes zu scannen, benötigt die App Zugriff auf die Kamera.", + "grantCameraPermissionDialogPermanentlyDenied": "Sie haben die Berechtigung für den Kamerazugriff permanent verweigert. Bitte aktivieren Sie die Berechtigung in den Einstellungen ihres Smartphones.", + "grantCameraPermissionDialogButton": "Berechtigung erteilen", + "decryptErrorTitle": "Entschlüsselung fehlgeschlagen", + "decryptErrorContent": "Leider konnten Ihre Token nicht entschlüsselt werden. Das deutet darauf hin, dass der Verschlüsselungsschlüssel nicht mehr verfügbar ist. Sie können es erneut versuchen oder die App Daten löschen. Dabei werden alle Token aus der App geschlöscht.", + "decryptErrorButtonDelete": "Löschen.", + "decryptErrorButtonSendError": "Fehler senden", + "decryptErrorButtonRetry": "Wiederholen", + "decryptErrorDeleteConfirmationContent": "Sind Sie sicher, dass Sie die App Daten löschen möchten?", + "hidePushTokens": "Push-Token ausblenden", + "hidePushTokensDescription": "Push-Token aus der Token-Liste ausblenden. Dadurch werden die Token nicht gelöscht und sind weiterhin auf einem separaten Bildschirm sichtbar.", + "licensesAndVersion": "Lizenzen und Version" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8624668e6..5f42ef8cb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2,134 +2,91 @@ "@@last_modified": "2023-08-07", "accept": "Accept", "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets accepted by the user." }, "decline": "Decline", "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets declined by the user." }, "name": "Name", "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens name should be entered." }, "secret": "Secret", "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens secret should be entered." }, "encoding": "Encoding", "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "algorithm": "Algorithm", "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "digits": "Digits", "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the number of digits for the opt value is selected." }, "type": "Type", "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the type of the token is selected." }, "period": "Period", "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the period of the totp token is selected." }, "rename": "Rename", "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes renaming the token." }, "cancel": "Cancel", "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} + "description": "Button to cancel an action." }, "delete": "Delete", "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes deleting the token." }, "dismiss": "Dismiss", "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} + "description": "Text of a button that closes a dialog." }, "addToken": "Add token", "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} + "description": "The button to open the screen to add tokens by hand." }, "scanQrCode": "Scan QR-Code", "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} + "description": "The button to scan otpauth qr-codes." }, "enterDetailsForToken": "Enter details for token", "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." }, "pleaseEnterANameForThisToken": "Please enter a name for this token.", "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a name for a token." }, "pleaseEnterASecretForThisToken": "Please enter a secret for this token.", "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a secret for a token." }, "theSecretDoesNotFitTheCurrentEncoding": "The secret does not fit the current encoding", "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user that the secret does not fit the selected encoding." }, "renameToken": "Rename token", "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token can be entered." }, "confirmDeletion": "Confirm deletion", "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a token can be deleted." }, "confirmDeletionOf": "Are you sure you want to delete {name}?", "@confirmDeletionOf": { "description": "Asks for confirmation on deleting a token.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234" @@ -138,20 +95,15 @@ }, "generatingPhonePart": "Generating phone part", "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone part gets generated right now." }, "phonePart": "Phone part:", "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." }, "otpValueCopiedMessage": "Password \"{otpValue}\" copied to clipboard.", "@otpValueCopiedMessage": { "description": "Tells the user that the otp value was copied to the clipboard.", - "type": "text", "placeholders": { "otpValue": { "example": "055374" @@ -160,98 +112,67 @@ }, "settings": "Settings", "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} + "description": "Button to open the settings page." }, "pushToken": "Push Token", "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} + "description": "Title for the settings block concerning the push tokens." }, "theme": "Theme", "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} + "description": "Title of the setting group where the theme can be selected." }, "lightTheme": "Light", "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} + "description": "The light theme." }, "darkTheme": "Dark", "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} + "description": "The dark theme." }, "systemTheme": "Use device's theme", "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} + "description": "The systems theme." }, "enablePolling": "Enable polling", "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} + "description": "Name of the setting switch that enables polling." }, "requestPushChallengesPeriodically": "Request push challenges from the server periodically. Enable this if push challenges are not received normally.", "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} + "description": "The description of the polling feature." }, "synchronizePushTokens": "Synchronize push tokens", "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Title of synchronizing push tokens in settings." }, "synchronizesTokensWithServer": "Synchronizes tokens with the privacyIDEA server.", "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Description of synchronizing push tokens in settings." }, "sync": "Sync", "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} + "description": "Text of button that is used to synchronize push tokens." }, "synchronizingTokens": "Synchronizing tokens.", "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} + "description": "Title of the push synchronization dialog." }, "allTokensSynchronized": "All tokens are synchronized.", "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." }, "synchronizationFailed": "Synchronization failed for the following tokens, please try again:", "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} + "description": "Headline for the list of tokens where the synchronization failed." }, "tokensDoNotSupportSynchronization": "The following tokens do not support synchronization and must be rolled out again:", "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." }, "errorRollOutFailed": "Rolling out token {name} failed.\nError code: {errorCode}", "@errorRollOutFailed": { "description": "Tells the user that the token could not be rolled out, because a network error occurred.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -263,20 +184,15 @@ }, "errorSynchronizationNoNetworkConnection": "Synchronizing tokens failed, privacyIDEA server could not be reached.", "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." }, "errorRollOutNoConnectionToServer": "Rolling out token {name} failed, the server could not be reached.", "@errorRollOutNoConnectionToServer": { - "description": "Tells the user that the roll-out failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because the server could not be reached." }, "errorRollOutUnknownError": "An unknown error occurred. Roll-out not possible: {e}", "@errorRollOutUnknownError": { "description": "Tells the user that the roll-out failed because of an unknown error.", - "type": "text", "placeholders": { "e": { "example": "IllegalArgumentException on Line 5 ..." @@ -285,391 +201,203 @@ }, "rollingOut": "Rolling out", "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} + "description": "Label that tells the user that the token is being rolled out." }, "pollingChallenges": "Polling for new challenges", "@pollingChallenges": { - "type": "text", "placeholders": {} }, "unexpectedError": "An unexpected error occurred.", "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} + "description": "Title of page report mode." }, "pollingFailNoNetworkConnection": "Polling failed. Server cannot be reached.", "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because no network connection is available." }, "useDeviceLocaleTitle": "Use device language", "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Title of the switch tile where using the devices language can be enabled." }, "useDeviceLocaleDescription": "Use device language if it is supported, otherwise default to english.", "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Description of the switch tile where using the devices language can be enabled." }, "language": "Language", "@language": { - "description": "Title of language setting group.", - "type": "text", - "placeholders": {} + "description": "Title of language setting group." }, "authenticateToShowOtp": "Please authenticate to show one time password.", "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to view a one time password." }, "authenticateToUnLockToken": "Please authenticate to change the lock status of the token.", "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to lock or unlock a token." }, "biometricRequiredTitle": "Biometrics not setup", "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, "biometricHint": "Authentication required", "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, "biometricNotRecognized": "Not recognized. Try again.", "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, "biometricSuccess": "Authentication successful", "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsRequiredTitle": "Device credentials not set up", "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsSetupDescription": "Setup device credentials in the device's settings", "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, "signInTitle": "Authentication required", "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, "goToSettingsButton": "Go to settings", "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, "goToSettingsDescription": "Authentication by credentials or biometrics is not set up on your device. Please set it up in the device's settings.", "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." }, "lockOut": "Biometric authentication is disabled. Please lock and unlock your screen to enable it.", "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, "authNotSupportedTitle": "Device credentials or biometrics required", "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." }, "authNotSupportedBody": "This action requires the device to be secured by credentials or biometrics.", "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." }, "lock": "Lock", "@lock": { - "description": "Description of button that locks a token.", - "type": "text" + "description": "Description of button that locks a token." }, "unlock": "Unlock", "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" + "description": "Description of button that unlocks a token." }, "noResultTitle": "No tokens stored yet.", "@noResultTitle": { - "description": "No tokens stored yet.", - "type": "text" + "description": "No tokens stored yet." }, "noResultText1": "Tap the ", "@noResultText1": { - "description": "first no result text", - "type": "text" + "description": "first no result text" }, "noResultText2": " button to get started!", "@noResultText2": { - "description": "second no result text", - "type": "text" + "description": "second no result text" }, "onBoardingTitle1": "{appName}", "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", "placeholders": { "appName": { - "type": "String", "example": "privacyIDEA Authenticator" } } }, "onBoardingText1": "Two-factor authentication\nmade easy", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, "onBoardingTitle2": "Maximum Security", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, "onBoardingText2": "Store tokens on your device securely\nProtected by your biometrics", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, "onBoardingTitle3": "Visit us at Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, "onBoardingText3": "This app is open source", - "@onBoardingText3": { - "description": "onBoardingText3", - "type": "text" - }, "errorLogTitle": "Error logs", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, "sendErrorHint": "Send us the error log via e-mail", "@sendErrorHint": { - "description": "Hint for the user about what he will send.", - "type": "text" + "description": "Hint for the user about what he will send." }, "enableVerboseLogging": "Enable verbose logging", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, "clearErrorLogHint": "Clears the local error log file", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, "logMenu": "Log menu", - "@logMenu": { - "description": "logMenu", - "type": "text" - }, "sendErrorDialogHeader": "Send via e-mail", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, "noLogToSend": "There is log to send.", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "The error log file is attached.", - "@errorLogFileAttached": { - "description": "Message for email body", - "type": "text" + "errorMailBody": "The error log file is attached.\nYou can replace this text with additional information about the error.", + "@errorMailBody": { + "description": "Message for email body" }, "errorLogCleared": "Error logs cleared.", - "@errorLogCleared": { - "description": "errorLogCleared", - "type": "text" - }, "showDetails": "Show details", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, "open": "Open", - "@open": { - "description": "open", - "type": "text" - }, "sendErrorDialogBody": "An unexpected error occurred in the application. The information below can be send to the developers by email to help prevent this error in the future.", "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" + "description": "Description shown to the user about what info the error report contains." }, "noFbToken": "No Firebase token available", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, "firebaseToken": "Firebase Token", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, "noPublicKey": "No public key available", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, "publicKey": "Public Key", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, "editToken": "Edit Token", - "@editToken": { - "description": "editToken", - "type": "text" - }, "edit": "Edit", - "@edit": { - "description": "edit", - "type": "text" - }, "save": "Save", - "@save": { - "description": "save", - "type": "text" - }, "validFor": "Valid for", - "@validFor": { - "description": "validFor", - "type": "text" - }, "validUntil": "Valid until", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, "deleteLockedToken": "Please authenticate to delete the locked token.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, "editLockedToken": "Please authenticate to edit the locked token.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, "uncollapseLockedFolder": "Please authenticate to uncollapse the locked folder.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, "renameTokenFolder": "Rename folder", "@renameTokenFolder": { - "description": "Title of the dialog where a new name for a token folder can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token folder can be entered." }, "addANewFolder": "Create new folder", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, "folderName": "Foldername", - "@folderName": { - "description": "folderName", - "type": "text" - }, "retryRollout": "Retry rollout", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, "generatingRSAKeyPair": "Generating RSA key pair", "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "generatingRSAKeyPairFailed": "Generating RSA key pair failed", "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKey": "Sending public RSA key", "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKeyFailed": "Sending public RSA key failed", "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponse": "Parsing response", "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponseFailed": "Parsing response failed", "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "rolloutCompleted": "Rollout completed", "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "authToAcceptPushRequest": "Please authenticate to accept the push request.", - "@authToAcceptPushRequest": { - "description": "authToAcceptPushRequest", - "type": "text" - }, "authToDeclinePushRequest": "Please authenticate to decline the push request.", - "@authToDeclinePushRequest": { - "description": "", - "type": "text" - }, "incomingAuthRequestError": "The message didn't provided the needed data or the data was malformed.", - "@incomingAuthRequestError": { - "description": "incomingAuthRequestError", - "type": "text" - }, "imageUrl": "Image URL", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, "errorRollOutSSLHandshakeFailed": "SSL handshake failed. Roll-out not possible.", "@errorRollOutSSLHandshakeFailed": { - "description": "Tells the user that the roll-out failed because the SSL handshake failed.", - "type": "text" + "description": "Tells the user that the roll-out failed because the SSL handshake failed." }, "errorWhenPullingChallenges": "An error occured when polling for challenges of {name}", "@errorWhenPullingChallenges": { "description": "errorWhenPullingChallenges", - "type": "text", "placeholders": { "name": { - "type": "String", "example": "PUSH1234A" } } @@ -677,7 +405,6 @@ "errorRollOutTokenExpired": "Rolling out this Token is not possible anymore.\nThe token {name} has expired.", "@errorRollOutTokenExpired": { "description": "Tells the user that the roll-out failed because the token has expired.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -685,28 +412,21 @@ } }, "yes": "Yes", - "@yes": { - "description": "yes", - "type": "text" - }, "no": "No", - "@no": { - "description": "no", - "type": "text" - }, "butDiscardIt": "but discard it", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, "declineIt": "decline it", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, "requestTriggerdByUserQuestion": "Was this request triggered by you?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } + "grantCameraPermissionDialogTitle": "Camera permission is not granted", + "grantCameraPermissionDialogContent": "Please grant camera permission to scan QR codes.", + "grantCameraPermissionDialogPermanentlyDenied": "Camera permission is permanently denied. Please grant camera permission in your Phone's settings.", + "grantCameraPermissionDialogButton": "Grant permission", + "decryptErrorTitle": "Decryption error", + "decryptErrorContent": "Unfortunately, the app was unable to decrypt your tokens. This indicates that the encryption key is broken. You can try again or delete the app data, which would delete the tokens in the app.", + "decryptErrorButtonDelete": "Delete", + "decryptErrorButtonSendError": "Send error", + "decryptErrorButtonRetry": "Retry", + "decryptErrorDeleteConfirmationContent": "Are you sure you want to delete the app data?", + "hidePushTokens": "Hide push tokens", + "hidePushTokensDescription": "Hide push tokens from the token list. This will not delete the tokens and they will still be visible on a separate screen.", + "licensesAndVersion": "Licenses and version" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4cd3fb4f0..6c8e95f04 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2,146 +2,99 @@ "@@last_modified": "2023-08-07", "guide": "guía", "@guide": { - "description": "Button to open the guide screen.", - "type": "text", - "placeholders": {} + "description": "Button to open the guide screen." }, "retry": "Reintentar", "@retry": { - "description": "Label for e.g. a button. Something is tried to be done again.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something is tried to be done again." }, "accept": "Aceptar", "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets accepted by the user." }, "decline": "Negar", "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets declined by the user." }, "name": "Nombre", "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens name should be entered." }, "secret": "Secreto", "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens secret should be entered." }, "encoding": "Codificación", "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "algorithm": "Algorithmo", "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "digits": "Dígitos", "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the number of digits for the opt value is selected." }, "type": "Tipo", "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the type of the token is selected." }, "period": "Periodo", "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the period of the totp token is selected." }, "rename": "Renombrar", "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes renaming the token." }, "cancel": "Anular", "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} + "description": "Button to cancel an action." }, "delete": "Borrar", "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes deleting the token." }, "dismiss": "Desestimar", "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} + "description": "Text of a button that closes a dialog." }, "addToken": "Añadir token", "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} + "description": "The button to open the screen to add tokens by hand." }, "scanQrCode": "Escanear código QR", "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} + "description": "The button to scan otpauth qr-codes." }, "enterDetailsForToken": "Introduzca los datos de el token", "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." }, "pleaseEnterANameForThisToken": "Introduzca un nombre para este token.", "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a name for a token." }, "pleaseEnterASecretForThisToken": "Por favor, introduzca un secreto para este token.", "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a secret for a token." }, "theSecretDoesNotFitTheCurrentEncoding": "El secreto no se ajusta a la codificación actual", "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user that the secret does not fit the selected encoding." }, "renameToken": "Renombrar token", "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token can be entered." }, "confirmDeletion": "Confiem supresión", "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a token can be deleted." }, "confirmDeletionOf": "¿Está seguro de que desea eliminar {name}?", "@confirmDeletionOf": { "description": "Asks for confirmation on deleting a token.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234" @@ -150,20 +103,15 @@ }, "generatingPhonePart": "Generar parte telefónico", "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone part gets generated right now." }, "phonePart": "Pieza de teléfono:", "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." }, "otpValueCopiedMessage": "Contraseña \"{otpValue}\" copiada en el portapapeles.", "@otpValueCopiedMessage": { "description": "Tells the user that the otp value was copied to the clipboard.", - "type": "text", "placeholders": { "otpValue": { "example": "055374" @@ -172,98 +120,67 @@ }, "settings": "Configuración", "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} + "description": "Button to open the settings page." }, "pushToken": "Push Token", "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} + "description": "Title for the settings block concerning the push tokens." }, "theme": "Tema", "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} + "description": "Title of the setting group where the theme can be selected." }, "lightTheme": "Luminoso", "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} + "description": "The light theme." }, "darkTheme": "Negro", "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} + "description": "The dark theme." }, "systemTheme": "Utilizar el tema del teléfono", "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} + "description": "The systems theme." }, "enablePolling": "Activar polling", "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} + "description": "Name of the setting switch that enables polling." }, "requestPushChallengesPeriodically": "Solicita retos push al servidor periódicamente. Habilite esta opción si los retos push no se reciben normalmente.", "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} + "description": "The description of the polling feature." }, "synchronizePushTokens": "Sinchronizar push tokens", "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Title of synchronizing push tokens in settings." }, "synchronizesTokensWithServer": "Sinchronizar tokens con el privacyIDEA servidor.", "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Description of synchronizing push tokens in settings." }, "sync": "Sinchronizar", "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} + "description": "Text of button that is used to synchronize push tokens." }, "synchronizingTokens": "Sincronización de los tokens.", "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} + "description": "Title of the push synchronization dialog." }, "allTokensSynchronized": "Todas los tokens están sincronizadas.", "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." }, "synchronizationFailed": "La sincronización ha fallado para los siguientes tokens, por favor inténtelo de nuevo:", "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} + "description": "Headline for the list of tokens where the synchronization failed." }, "tokensDoNotSupportSynchronization": "Las siguientes tokens no admiten la sincronización y deben volver a desplegarse:", "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." }, "errorRollOutFailed": "Error en la extracción de el token {name}. Código de error: {errorCode}", "@errorRollOutFailed": { "description": "Tells the user that the token could not be rolled out, because a network error occurred.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -275,14 +192,11 @@ }, "errorSynchronizationNoNetworkConnection": "Error al sincronizar los tokens. No se ha podido acceder al servidor de PrivacyIDEA.", "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." }, "errorRollOutUnknownError": "An unknown error occurred. Roll-out not possible: {e}", "@errorRollOutUnknownError": { "description": "Tells the user that the roll-out failed because of an unknown error.", - "type": "text", "placeholders": { "e": { "example": "IllegalArgumentException on Line 5 ..." @@ -291,362 +205,190 @@ }, "rollingOut": "Despliegue", "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} + "description": "Label that tells the user that the token is being rolled out." }, "pollingChallenges": "Sondeo para nuevos challenges", "@pollingChallenges": { - "type": "text", "placeholders": {} }, "unexpectedError": "Se ha producido un error inesperado.", "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} + "description": "Title of page report mode." }, "pollingFailNoNetworkConnection": "Error de sondeo. No se puede acceder al servidor.", "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because no network connection is available." }, "useDeviceLocaleTitle": "Utiliza el idioma del teléfono", "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Title of the switch tile where using the devices language can be enabled." }, "useDeviceLocaleDescription": "Utilizar el idioma del dispositivo si está soportado, en caso contrario por defecto inglés.", "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Description of the switch tile where using the devices language can be enabled." }, "language": "Language", "@language": { - "description": "Título del grupo de configuración lingüística.", - "type": "text", - "placeholders": {} + "description": "Título del grupo de configuración lingüística." }, "authenticateToShowOtp": "Por favor, autentifíquese para mostrar la contraseña de una sola vez.", "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to view a one time password." }, "authenticateToUnLockToken": "Por favor, autentifíquese para cambiar el estado de bloqueo del token.", "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to lock or unlock a token." }, "biometricRequiredTitle": "Biometría no configurada", "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, "biometricHint": "AAutenticación necesaria", "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, "biometricNotRecognized": "No reconocido. Inténtelo de nuevo.", "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, "biometricSuccess": "Autenticación correcta", "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsRequiredTitle": "No se han configurado las credenciales del dispositivo.", "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsSetupDescription": "Configurar las credenciales del dispositivo en los ajustes del dispositivo", "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, "signInTitle": "Autenticación necesaria", "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, "goToSettingsButton": "Ir a la configuración", "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, "goToSettingsDescription": "La autenticación por credenciales o biométrica no está configurada en tu dispositivo. Por favor, configúrala en los ajustes del dispositivo.", "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." }, "lockOut": "La autenticación biométrica está desactivada. Bloquea y desbloquea la pantalla para activarla.", "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, "authNotSupportedTitle": "Se requieren credenciales de dispositivo o datos biométricos", "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." }, "authNotSupportedBody": "Esta acción requiere que el dispositivo esté protegido mediante credenciales o datos biométricos.", "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." }, "lock": "Cierre", "@lock": { - "description": "Description of button that locks a token.", - "type": "text" + "description": "Description of button that locks a token." }, "unlock": "Desbloquear", "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" + "description": "Description of button that unlocks a token." }, "noResultTitle": "Aún no hay tokens almacenadas.", "@noResultTitle": { - "description": "No tokens stored yet.", - "type": "text" + "description": "No tokens stored yet." }, "noResultText1": "Indique el ", "@noResultText1": { - "description": "first no result text", - "type": "text" + "description": "first no result text" }, "noResultText2": " para empezar.", "@noResultText2": { - "description": "second no result text", - "type": "text" + "description": "second no result text" }, "onBoardingTitle1": "{appName}", "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", "placeholders": { "appName": { - "type": "String", "example": "privacyIDEA Authenticator" } } }, "onBoardingText1": "Autenticación de dos factores\nmuy fácil", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, "onBoardingTitle2": "Máxima seguridad", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, "onBoardingText2": "Almacena tokens en tu teléfono/n de forma segura. Protegido por tus datos biométricos.", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, "onBoardingTitle3": "Visítenos en Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, "onBoardingText3": "Esta aplicación es de código abierto", - "@onBoardingText3": { - "description": "onBoardingText3", - "type": "text" - }, "errorLogTitle": "Registros de errores", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, "sendErrorHint": "Envíanos el registro de errores por correo electrónico", "@sendErrorHint": { - "description": "Hint for the user about what he will send.", - "type": "text" + "description": "Hint for the user about what he will send." }, "enableVerboseLogging": "Activar el registro detallado", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, "clearErrorLogHint": "Borra el archivo de registro de errores local", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, "logMenu": "Menú Registros", - "@logMenu": { - "description": "deleteErrorLogs", - "type": "text" - }, "sendErrorDialogHeader": "Enviar por correo electrónico", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, "noLogToSend": "Hay log para enviar", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "Se adjunta el archivo de registro de errores", - "@errorLogFileAttached": { - "description": "Message for email body", - "type": "text" + "errorMailBody": "Se adjunta el archivo de registro de errores.\nPuede sustituir este texto por información adicional sobre el error.", + "@errorMailBody": { + "description": "Message for email body" }, "errorLogCleared": "Registros de error borrados", - "@errorLogCleared": { - "description": "errorLogCleared", - "type": "text" - }, "showDetails": "Mostrar detalles", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, "open": "Abrir", - "@open": { - "description": "open", - "type": "text" - }, "sendErrorDialogBody": "Se ha producido un error inesperado en la aplicación. La siguiente información puede ser enviada a los desarrolladores por correo electrónico para ayudar a prevenir este error en el futuro.", "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" + "description": "Description shown to the user about what info the error report contains." }, "noFbToken": "No hay token de Firebase.", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, "firebaseToken": "Token de Firebase", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, "noPublicKey": "No hay clave pública.", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, "publicKey": "Clave pública", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, "editToken": "Editar token", - "@editToken": { - "description": "editToken", - "type": "text" - }, "edit": "Editar", - "@edit": { - "description": "edit", - "type": "text" - }, "save": "Guardar", - "@save": { - "description": "save", - "type": "text" - }, "validFor": "Válido para", - "@validFor": { - "description": "validFor", - "type": "text" - }, "validUntil": "Válido hasta", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, "deleteLockedToken": "Por favor, autentíquese para eliminar el token bloqueado.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, "editLockedToken": "Por favor, autentíquese para editar el token bloqueado.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, "uncollapseLockedFolder": "Por favor, autentifíquese para abrir la carpeta bloqueada.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, "renameTokenFolder": "Cambiar nombre de carpeta", - "@renameTokenFolder": { - "description": "renameTokenFolder", - "type": "text" - }, "addANewFolder": "Crear nueva carpeta", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, "folderName": "Nombre de la carpeta", - "@folderName": { - "description": "folderName", - "type": "text" - }, "retryRollout": "Reintentar despliegue", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, "generatingRSAKeyPair": "Generando par de claves RSA", "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "generatingRSAKeyPairFailed": "Error al generar el par de claves RSA", "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKey": "Enviando clave pública RSA", "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKeyFailed": "Error al enviar la clave pública RSA", "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponse": "Analizando la respuesta", "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponseFailed": "Error al analizar la respuesta", "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "rolloutCompleted": "Despliegue completado", "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "errorRollOutNoConnectionToServer": "El despliegue del token {name} ha fallado, no se ha podido acceder al servidor.", "@errorRollOutNoConnectionToServer": { "description": "Message for the rollout process", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -654,45 +396,20 @@ } }, "authToAcceptPushRequest": "Por favor, autentifíquese para aceptar la solicitud push.", - "@authToAcceptPushRequest": { - "description": "authToAcceptPushRequest", - "type": "text" - }, "authToDeclinePushRequest": "Por favor, autentifíquese para rechazar la solicitud push.", - "@authToDeclinePushRequest": { - "description": "authToDeclinePushRequest", - "type": "text" - }, "incomingAuthRequestError": "El mensaje no proporcionaba los datos necesarios o los datos estaban malformados.", - "@incomingAuthRequestError": { - "description": "incomingAuthRequestError", - "type": "text" - }, "imageUrl": "URL de la imagen", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, "errorRollOutSSLHandshakeFailed": "Ha fallado el protocolo SSL. No es posible el despliegue.", - "@errorRollOutSSLHandshakeFailed": { - "description": "errorRollOutSSLHandshakeFailed", - "type": "text" - }, "errorWhenPullingChallenges": "Se ha producido un error al buscar retos de {name}", "@errorWhenPullingChallenges": { - "description": "errorWhenPullingChallenges", - "type": "text", "placeholders": { "name": { - "type": "String", "example": "PUSH1234A" } } }, "errorRollOutTokenExpired": "El despliegue de este token ya no es posible.\nEl token {name} ha caducado.", "@errorRollOutTokenExpired": { - "description": "errorRollOutTokenExpired", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -700,28 +417,21 @@ } }, "yes": "Sí", - "@yes": { - "description": "yes", - "type": "text" - }, "no": "No", - "@no": { - "description": "no", - "type": "text" - }, "butDiscardIt": "pero descártelo", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, "declineIt": "rechazar", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, "requestTriggerdByUserQuestion": "¿Fue usted quien provocó esta petición?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } + "grantCameraPermissionDialogTitle": "El permiso de cámara no está concedido", + "grantCameraPermissionDialogContent": "Por favor, concede permiso a la cámara para escanear códigos QR", + "grantCameraPermissionDialogPermanentlyDenied": "El permiso de cámara está denegado permanentemente. Concede el permiso de cámara en la configuración del teléfono", + "grantCameraPermissionDialogButton": "Conceder permiso", + "decryptErrorTitle": "Error de descifrado", + "decryptErrorContent": "Lamentablemente, la aplicación no ha podido descifrar tus tokens. Esto indica que la clave de cifrado está rota. Puedes volver a intentarlo o borrar los datos de la app, lo que eliminaría los tokens de la app.", + "decryptErrorButtonDelete": "Borrar", + "decryptErrorButtonSendError": "Enviar error", + "decryptErrorButtonRetry": "Reintentar", + "decryptErrorDeleteConfirmationContent": "¿Estás seguro de que quieres borrar los datos de la aplicación?", + "hidePushTokens": "Ocultar tokens push", + "hidePushTokensDescription": "Ocultar tokens push de la lista de tokens. Esto no borrará los tokens y seguirán siendo visibles en una pantalla aparte", + "licensesAndVersion": "Licencias y versión" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0e4ad4b9f..4aafe4020 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2,141 +2,95 @@ "@@last_modified": "2023-08-07", "guide": "Assistant", "@guide": { - "description": "Button to open the guide screen.", - "type": "text", - "placeholders": {} + "description": "Button to open the guide screen." }, "retry": "Réessayer", "@retry": { - "description": "Label for e.g. a button. Something is tried to be done again.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something is tried to be done again." }, "accept": "Accepter", "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets accepted by the user." }, "decline": "Refuser", "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets declined by the user." }, "name": "Nom", "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens name should be entered." }, "secret": "Secret", "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens secret should be entered." }, "encoding": "Encodage", "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "algorithm": "Algorithme", "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "digits": "Chiffres", "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the number of digits for the opt value is selected." }, "type": "Type", "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the type of the token is selected." }, "period": "Période", "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the period of the totp token is selected." }, "rename": "Renommer", "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes renaming the token." }, "cancel": "Annuler", "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} + "description": "Button to cancel an action." }, "delete": "Supprimer", "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes deleting the token." }, "dismiss": "Fermer", "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} + "description": "Text of a button that closes a dialog." }, "addToken": "Ajouter un jeton", "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} + "description": "The button to open the screen to add tokens by hand." }, "scanQrCode": "Numériser QR-Code", "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} + "description": "The button to scan otpauth qr-codes." }, "enterDetailsForToken": "Saisissez les détails du jeton", "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." }, "pleaseEnterANameForThisToken": "Veuillez saisir un nom pour ce jeton.", "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a name for a token." }, "pleaseEnterASecretForThisToken": "Veuillez saisir un secret pour ce jeton.", "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a secret for a token." }, "theSecretDoesNotFitTheCurrentEncoding": "Le secret n'est pas compatible avec \nl'encodage actuel.", "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user that the secret does not fit the selected encoding." }, "renameToken": "Renommer jeton", "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token can be entered." }, "confirmDeletion": "Confirmer suppression", "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a token can be deleted." }, "confirmDeletionOf": "Confirmer la suppression de {name}?", "@confirmDeletionOf": { @@ -150,15 +104,11 @@ }, "generatingPhonePart": "Générer la part du téléphone", "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone part gets generated right now." }, "phonePart": "Part du téléphone:", "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." }, "otpValueCopiedMessage": "Le mot de passe \"{otpValue}\" a été copié dans le presse-papier.", "@otpValueCopiedMessage": { @@ -172,93 +122,63 @@ }, "settings": "Paramètres", "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} + "description": "Button to open the settings page." }, "pushToken": "Jeton de type Push", "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} + "description": "Title for the settings block concerning the push tokens." }, "theme": "Thème", "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} + "description": "Title of the setting group where the theme can be selected." }, "lightTheme": "Thème lumineux", "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} + "description": "The light theme." }, "darkTheme": "Thème sombre", "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} + "description": "The dark theme." }, "systemTheme": "Utiliser le thème de l'appareil", "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} + "description": "The systems theme." }, "enablePolling": "Activer l'interrogation du serveur.", "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} + "description": "Name of the setting switch that enables polling." }, "requestPushChallengesPeriodically": "Demander des challenges push depuis le serveur périodiquement. Activer cette fonction si les challenges push ne sont pas reçus normalement.", "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} + "description": "The description of the polling feature." }, "synchronizePushTokens": "Synchoniser les jetons Push", "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Title of synchronizing push tokens in settings." }, "synchronizesTokensWithServer": "Synchroniser les jetons Push avec le serveur privacyIDEA.", "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Description of synchronizing push tokens in settings." }, "sync": "Synchroniser", "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} + "description": "Text of button that is used to synchronize push tokens." }, "synchronizingTokens": "Synchroniser les jetons.", "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} + "description": "Title of the push synchronization dialog." }, "allTokensSynchronized": "Tous les jetons ont été synchronisés.", "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." }, "synchronizationFailed": "La synchronisation a échoué pour ces jetons, veuillez reéssayer:", "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} + "description": "Headline for the list of tokens where the synchronization failed." }, "tokensDoNotSupportSynchronization": "Ces jetons ne supportent pas la synchronisation et doivent être de nouveau générés:", "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." }, "errorRollOutFailed": "Le déploiement du jeton {name} a échoué. Erreur réseau: {errorCode}", "@errorRollOutFailed": { @@ -275,9 +195,7 @@ }, "errorSynchronizationNoNetworkConnection": "La synchronization a échoué car le serveur est injoignable.", "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." }, "errorRollOutUnknownError": "Le déploiement a échoué suite à une erreur inconnue: {e}", "@errorRollOutUnknownError": { @@ -291,403 +209,215 @@ }, "rollingOut": "Déploiement en cours", "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} + "description": "Label that tells the user that the token is being rolled out." }, "pollingChallenges": "Vérification de nouveaux challenges", "@pollingChallenges": { - "type": "text", - "placeholders": {} + "type": "text" }, "unexpectedError": "Une erreur inattendue s'est produite.", "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} + "description": "Title of page report mode." }, "pollingFailNoNetworkConnection": "L'interrogation a échoué. Le serveur est injoignable.", "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because no network connection is available." }, "useDeviceLocaleTitle": "Utiliser la langue de l'appareil", "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Title of the switch tile where using the devices language can be enabled." }, "useDeviceLocaleDescription": "Utilisez la langue de l'appareil si elle est prise en charge, sinon l'anglais par défaut.", "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Description of the switch tile where using the devices language can be enabled." }, "language": "Langue", "@language": { - "description": "Title of language setting group.", - "type": "text", - "placeholders": {} + "description": "Title of language setting group." }, "authenticateToShowOtp": "Veuillez vous authentifier pour afficher un mot de passe à usage unique.", "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to view a one time password." }, "authenticateToUnLockToken": "Veuillez vous authentifier pour modifier l'état de verrouillage du jeton.", "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to lock or unlock a token." }, "biometricRequiredTitle": "La biométrie n'est pas configurée", "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, "biometricHint": "Authentification requise", "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, "biometricNotRecognized": "Pas reconnu. Réessayer.", "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, "biometricSuccess": "Authentification réussie", "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsRequiredTitle": "Les informations d'identification de l'appareil ne sont pas configurées", "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsSetupDescription": "Configurer les informations d'identification de l'appareil dans les paramètres de l'appareil", "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, "signInTitle": "Authentification requise", "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, "goToSettingsButton": "Aller aux paramètres", "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, "goToSettingsDescription": "L'authentification par identifiants ou biométrie n'est pas configurée sur votre appareil. Veuillez le configurer dans les paramètres de l'appareil.", "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." }, "lockOut": "L'authentification biométrique est désactivée. Veuillez verrouiller et déverrouiller votre écran pour l'activer.", "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, "authNotSupportedTitle": "Informations d'identification de l'appareil ou données biométriques requises", "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." }, "authNotSupportedBody": "Cette action nécessite que l'appareil soit sécurisé par des identifiants ou des données biométriques.", "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." }, "lock": "Bloquer", "@lock": { - "description": "Description of button that locks a token.", - "type": "text" + "description": "Description of button that locks a token." }, "unlock": "Ouvrir", "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" + "description": "Description of button that unlocks a token." }, "noResultTitle": "Aucun jeton n'est encore stocké.", "@noResultTitle": { - "description": "No tokens stored yet.", - "type": "text" + "description": "No tokens stored yet." }, "noResultText1": "Appuyez sur le \n", "@noResultText1": { - "description": "first noresult text", - "type": "text" + "description": "first noresult text" }, "noResultText2": "bouton pour commencer!", "@noResultText2": { - "description": "second noresult text", - "type": "text" + "description": "second noresult text" }, "onBoardingTitle1": "{appName}", "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", "placeholders": { "appName": { - "type": "String", "example": "privacyIDEA Authenticator" } } }, "onBoardingText1": "Authentification à deux facteurs\nrendue facile", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, "onBoardingTitle2": "Sécurité Maximale", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, "onBoardingText2": "Stockez les jetons sur votre \nappareil en toute sécurité\nProtégé par votre biométrie", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, "onBoardingTitle3": "Rendez-nous visite sur Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, "onBoardingText3": "Cette application est open source", - "@onBoardingText3": { - "description": "onBoardingText3", - "type": "text" - }, "errorLogTitle": "Journaux d'erreurs", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, "sendErrorHint": "Envoyez-nous le journal des erreurs par courrier électronique", "@sendErrorHint": { - "description": "Indice pour l'utilisateur sur ce qu'il va envoyer", - "type": "text" + "description": "Indice pour l'utilisateur sur ce qu'il va envoyer" }, "enableVerboseLogging": "Activer la journalisation verbeuse", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, "clearErrorLogHint": "Efface le fichier journal des erreurs locales", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, "logMenu": "Menu journal", "@logMenu": { - "description": "logMenu", - "type": "text" + "description": "logMenu" }, "sendErrorDialogHeader": "Envoyer par e-mail", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, "noLogToSend": "Il y a un journal à envoyer", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "Le fichier journal des erreurs est joint", - "@errorLogFileAttached": { - "description": "Message pour le corps du courriel", - "type": "text" + "errorMailBody": "Le fichier journal des erreurs est joint.\nVous pouvez remplacer ce texte par des informations supplémentaires sur l'erreur.", + "@errorMailBody": { + "description": "Message pour le corps du courriel" }, "errorLogCleared": "Journaux d'erreur effacés", - "@errorLogCleared": { - "description": "errorLogsCleard", - "type": "text" - }, "showDetails": "Afficher les détails", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, "open": "Ouvrir", - "@open": { - "description": "open", - "type": "text" - }, "sendErrorDialogBody": "Une erreur inattendue est survenue dans l'application. L'information suivante peut être transmise aux développeurs par email afin d'aider à corriger cette erreur dans le futur.", "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" + "description": "Description shown to the user about what info the error report contains." }, "noFbToken": "Pas de jeton Firebase", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, "firebaseToken": "Jeton Firebase", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, "noPublicKey": "Pas de clé publique", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, "publicKey": "Clé publique", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, "editToken": "Modifier le jeton", - "@editToken": { - "description": "editToken", - "type": "text" - }, "edit": "Modifier", - "@edit": { - "description": "edit", - "type": "text" - }, "save": "Enregistrer", - "@save": { - "description": "save", - "type": "text" - }, "validFor": "Valide pour", - "@validFor": { - "description": "validFor", - "type": "text" - }, "validUntil": "Valide jusqu'à", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, "deleteLockedToken": "Veuillez vous authentifier pour supprimer le jeton verrouillé.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, "editLockedToken": "Veuillez vous authentifier pour modifier le jeton verrouillé.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, "uncollapseLockedFolder": "Veuillez vous authentifier pour ouvrir le dossier verrouillé.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, "renameTokenFolder": "Renommer le dossier", - "@renameTokenFolder": { - "description": "renameTokenFolder", - "type": "text" - }, "addANewFolder": "Créer un nouveau dossier", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, "folderName": "Nom du dossier", - "@folderName": { - "description": "folderName", - "type": "text" - }, "retryRollout": "Réessayer le déploiement", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, "generatingRSAKeyPair": "Génération de la paire de clés RSA", "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "generatingRSAKeyPairFailed": "La génération de la paire de clés RSA a échoué", "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKey": "Envoi de la clé publique RSA", "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKeyFailed": "L'envoi de la clé publique RSA a échoué", "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponse": "Analyse de la réponse", "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponseFailed": "L'analyse de la réponse a échoué", "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "rolloutCompleted": "Déploiement terminé", "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "errorRollOutNoConnectionToServer": "El despliegue del token {name} ha fallado, no se ha podido acceder al servidor.", "@errorRollOutNoConnectionToServer": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "authToAcceptPushRequest": "Veuillez vous authentifier pour accepter la demande de connexion.", "@authToAcceptPushRequest": { - "description": "authToAcceptPushRequest", - "type": "text" + "description": "authToAcceptPushRequest" }, "authToDeclinePushRequest": "Veuillez vous authentifier pour refuser la demande de connexion.", "@authToDeclinePushRequest": { - "description": "authToDeclinePushRequest", - "type": "text" + "description": "authToDeclinePushRequest" }, "incomingAuthRequestError": "Le message ne contenait pas les données nécessaires ou les données étaient mal formées.", - "@incomingAuthRequestError": { - "description": "incomingAuthRequestError", - "type": "text" - }, "imageUrl": "URL de l'image", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, "errorRollOutSSLHandshakeFailed": "Échec de la prise de contact SSL. Le déploiement n'est pas possible.", - "@errorRollOutSSLHandshakeFailed": { - "description": "errorRollOutSSLHandshakeFailed", - "type": "text" - }, "errorWhenPullingChallenges": "Une erreur s'est produite lors de l'interrogation des défis de {name}", "@errorWhenPullingChallenges": { - "description": "errorWhenPullingChallenges", - "type": "text", "placeholders": { "name": { - "type": "String", "example": "PUSH1234A" } } }, "errorRollOutTokenExpired": "Le déploiement de ce jeton n'est plus possible. Le jeton {name} a expiré.", "@errorRollOutTokenExpired": { - "description": "errorRollOutTokenExpired", - "type": "text", "placeholders": { "name": { "example": "PUSH1234" @@ -695,28 +425,21 @@ } }, "yes": "Oui", - "@yes": { - "description": "yes", - "type": "text" - }, "no": "Non", - "@no": { - "description": "no", - "type": "text" - }, "butDiscardIt": "mais l'écarter", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, "declineIt": "refuser", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, "requestTriggerdByUserQuestion": "Cette demande a-t-elle été déclenchée par vous ?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } + "grantCameraPermissionDialogTitle": "L'autorisation de la caméra n'est pas accordée", + "grantCameraPermissionDialogContent": "Veuillez accorder à la caméra l'autorisation de scanner les codes QR", + "grantCameraPermissionDialogPermanentlyDenied": "L'autorisation de l'appareil photo est refusée de manière permanente. Veuillez accorder l'autorisation à l'appareil photo dans les paramètres de votre téléphone.", + "grantCameraPermissionDialogButton": "Accorder l'autorisation", + "decryptErrorTitle": "Erreur de décryptage", + "decryptErrorContent": "Malheureusement, l'application n'a pas pu décrypter vos jetons. Cela indique que la clé de cryptage est cassée. Vous pouvez réessayer ou supprimer les données de l'application, ce qui supprimera les jetons dans l'application.", + "decryptErrorButtonDelete": "Supprimer", + "decryptErrorButtonSendError": "Erreur d'envoi", + "decryptErrorButtonRetry": "Réessayer", + "decryptErrorDeleteConfirmationContent": "Êtes-vous sûr de vouloir supprimer les données de l'application ?", + "hidePushTokens": "Hide push tokens", + "hidePushTokensDescription": "Masquer les jetons de poussée de la liste des jetons. Cela ne supprimera pas les jetons et ils seront toujours visibles sur un écran séparé", + "licensesAndVersion": "Licences et version" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 000000000..1d04a36fd --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,964 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_cs.dart'; +import 'app_localizations_de.dart'; +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; +import 'app_localizations_fr.dart'; +import 'app_localizations_nl.dart'; +import 'app_localizations_pl.dart'; + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('cs'), + Locale('de'), + Locale('en'), + Locale('es'), + Locale('fr'), + Locale('nl'), + Locale('pl') + ]; + + /// Label for e.g. a button. Something gets accepted by the user. + /// + /// In en, this message translates to: + /// **'Accept'** + String get accept; + + /// Label for e.g. a button. Something gets declined by the user. + /// + /// In en, this message translates to: + /// **'Decline'** + String get decline; + + /// Describes the field where the tokens name should be entered. + /// + /// In en, this message translates to: + /// **'Name'** + String get name; + + /// Describes the field where the tokens secret should be entered. + /// + /// In en, this message translates to: + /// **'Secret'** + String get secret; + + /// Title of the dropdown button where the encoding is selected. + /// + /// In en, this message translates to: + /// **'Encoding'** + String get encoding; + + /// Title of the dropdown button where the encoding is selected. + /// + /// In en, this message translates to: + /// **'Algorithm'** + String get algorithm; + + /// Title of the dropdown button where the number of digits for the opt value is selected. + /// + /// In en, this message translates to: + /// **'Digits'** + String get digits; + + /// Title of the dropdown button where the type of the token is selected. + /// + /// In en, this message translates to: + /// **'Type'** + String get type; + + /// Title of the dropdown button where the period of the totp token is selected. + /// + /// In en, this message translates to: + /// **'Period'** + String get period; + + /// Label that describes renaming the token. + /// + /// In en, this message translates to: + /// **'Rename'** + String get rename; + + /// Button to cancel an action. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// Label that describes deleting the token. + /// + /// In en, this message translates to: + /// **'Delete'** + String get delete; + + /// Text of a button that closes a dialog. + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get dismiss; + + /// The button to open the screen to add tokens by hand. + /// + /// In en, this message translates to: + /// **'Add token'** + String get addToken; + + /// The button to scan otpauth qr-codes. + /// + /// In en, this message translates to: + /// **'Scan QR-Code'** + String get scanQrCode; + + /// Title of the screen where tokens are created manually, tells the user to enter all required values. + /// + /// In en, this message translates to: + /// **'Enter details for token'** + String get enterDetailsForToken; + + /// Hint telling the user to enter a name for a token. + /// + /// In en, this message translates to: + /// **'Please enter a name for this token.'** + String get pleaseEnterANameForThisToken; + + /// Hint telling the user to enter a secret for a token. + /// + /// In en, this message translates to: + /// **'Please enter a secret for this token.'** + String get pleaseEnterASecretForThisToken; + + /// Hint telling the user that the secret does not fit the selected encoding. + /// + /// In en, this message translates to: + /// **'The secret does not fit the current encoding'** + String get theSecretDoesNotFitTheCurrentEncoding; + + /// Title of the dialog where a new name for a token can be entered. + /// + /// In en, this message translates to: + /// **'Rename token'** + String get renameToken; + + /// Title of the dialog where a token can be deleted. + /// + /// In en, this message translates to: + /// **'Confirm deletion'** + String get confirmDeletion; + + /// Asks for confirmation on deleting a token. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete {name}?'** + String confirmDeletionOf(Object name); + + /// Title of a dialog telling the user that the phone part gets generated right now. + /// + /// In en, this message translates to: + /// **'Generating phone part'** + String get generatingPhonePart; + + /// Title of a dialog telling the user that the phone was generated, and it is shown to the user. + /// + /// In en, this message translates to: + /// **'Phone part:'** + String get phonePart; + + /// Tells the user that the otp value was copied to the clipboard. + /// + /// In en, this message translates to: + /// **'Password \"{otpValue}\" copied to clipboard.'** + String otpValueCopiedMessage(Object otpValue); + + /// Button to open the settings page. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// Title for the settings block concerning the push tokens. + /// + /// In en, this message translates to: + /// **'Push Token'** + String get pushToken; + + /// Title of the setting group where the theme can be selected. + /// + /// In en, this message translates to: + /// **'Theme'** + String get theme; + + /// The light theme. + /// + /// In en, this message translates to: + /// **'Light'** + String get lightTheme; + + /// The dark theme. + /// + /// In en, this message translates to: + /// **'Dark'** + String get darkTheme; + + /// The systems theme. + /// + /// In en, this message translates to: + /// **'Use device\'s theme'** + String get systemTheme; + + /// Name of the setting switch that enables polling. + /// + /// In en, this message translates to: + /// **'Enable polling'** + String get enablePolling; + + /// The description of the polling feature. + /// + /// In en, this message translates to: + /// **'Request push challenges from the server periodically. Enable this if push challenges are not received normally.'** + String get requestPushChallengesPeriodically; + + /// Title of synchronizing push tokens in settings. + /// + /// In en, this message translates to: + /// **'Synchronize push tokens'** + String get synchronizePushTokens; + + /// Description of synchronizing push tokens in settings. + /// + /// In en, this message translates to: + /// **'Synchronizes tokens with the privacyIDEA server.'** + String get synchronizesTokensWithServer; + + /// Text of button that is used to synchronize push tokens. + /// + /// In en, this message translates to: + /// **'Sync'** + String get sync; + + /// Title of the push synchronization dialog. + /// + /// In en, this message translates to: + /// **'Synchronizing tokens.'** + String get synchronizingTokens; + + /// Content of the push synchronization dialog. Signaling the user that everything worked. + /// + /// In en, this message translates to: + /// **'All tokens are synchronized.'** + String get allTokensSynchronized; + + /// Headline for the list of tokens where the synchronization failed. + /// + /// In en, this message translates to: + /// **'Synchronization failed for the following tokens, please try again:'** + String get synchronizationFailed; + + /// Informs the user that the following tokens cannot be synchronized as they do not support that. + /// + /// In en, this message translates to: + /// **'The following tokens do not support synchronization and must be rolled out again:'** + String get tokensDoNotSupportSynchronization; + + /// Tells the user that the token could not be rolled out, because a network error occurred. + /// + /// In en, this message translates to: + /// **'Rolling out token {name} failed.\nError code: {errorCode}'** + String errorRollOutFailed(Object name, Object errorCode); + + /// Tells the user that synchronizing the push tokens failed because the server could not be reached. + /// + /// In en, this message translates to: + /// **'Synchronizing tokens failed, privacyIDEA server could not be reached.'** + String get errorSynchronizationNoNetworkConnection; + + /// Tells the user that the roll-out failed because the server could not be reached. + /// + /// In en, this message translates to: + /// **'Rolling out token {name} failed, the server could not be reached.'** + String errorRollOutNoConnectionToServer(Object name); + + /// Tells the user that the roll-out failed because of an unknown error. + /// + /// In en, this message translates to: + /// **'An unknown error occurred. Roll-out not possible: {e}'** + String errorRollOutUnknownError(Object e); + + /// Label that tells the user that the token is being rolled out. + /// + /// In en, this message translates to: + /// **'Rolling out'** + String get rollingOut; + + /// No description provided for @pollingChallenges. + /// + /// In en, this message translates to: + /// **'Polling for new challenges'** + String get pollingChallenges; + + /// Title of page report mode. + /// + /// In en, this message translates to: + /// **'An unexpected error occurred.'** + String get unexpectedError; + + /// Tells the user that the roll-out failed because no network connection is available. + /// + /// In en, this message translates to: + /// **'Polling failed. Server cannot be reached.'** + String get pollingFailNoNetworkConnection; + + /// Title of the switch tile where using the devices language can be enabled. + /// + /// In en, this message translates to: + /// **'Use device language'** + String get useDeviceLocaleTitle; + + /// Description of the switch tile where using the devices language can be enabled. + /// + /// In en, this message translates to: + /// **'Use device language if it is supported, otherwise default to english.'** + String get useDeviceLocaleDescription; + + /// Title of language setting group. + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// Reason to authenticate when trying to view a one time password. + /// + /// In en, this message translates to: + /// **'Please authenticate to show one time password.'** + String get authenticateToShowOtp; + + /// Reason to authenticate when trying to lock or unlock a token. + /// + /// In en, this message translates to: + /// **'Please authenticate to change the lock status of the token.'** + String get authenticateToUnLockToken; + + /// Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters. + /// + /// In en, this message translates to: + /// **'Biometrics not setup'** + String get biometricRequiredTitle; + + /// Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters. + /// + /// In en, this message translates to: + /// **'Authentication required'** + String get biometricHint; + + /// Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters. + /// + /// In en, this message translates to: + /// **'Not recognized. Try again.'** + String get biometricNotRecognized; + + /// Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters. + /// + /// In en, this message translates to: + /// **'Authentication successful'** + String get biometricSuccess; + + /// Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters. + /// + /// In en, this message translates to: + /// **'Device credentials not set up'** + String get deviceCredentialsRequiredTitle; + + /// Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side. + /// + /// In en, this message translates to: + /// **'Setup device credentials in the device\'s settings'** + String get deviceCredentialsSetupDescription; + + /// Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters. + /// + /// In en, this message translates to: + /// **'Authentication required'** + String get signInTitle; + + /// Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters. + /// + /// In en, this message translates to: + /// **'Go to settings'** + String get goToSettingsButton; + + /// Message advising the user to go to the settings and configure device credentials or biometrics on their device. + /// + /// In en, this message translates to: + /// **'Authentication by credentials or biometrics is not set up on your device. Please set it up in the device\'s settings.'** + String get goToSettingsDescription; + + /// Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side. + /// + /// In en, this message translates to: + /// **'Biometric authentication is disabled. Please lock and unlock your screen to enable it.'** + String get lockOut; + + /// Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action. + /// + /// In en, this message translates to: + /// **'Device credentials or biometrics required'** + String get authNotSupportedTitle; + + /// Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action. + /// + /// In en, this message translates to: + /// **'This action requires the device to be secured by credentials or biometrics.'** + String get authNotSupportedBody; + + /// Description of button that locks a token. + /// + /// In en, this message translates to: + /// **'Lock'** + String get lock; + + /// Description of button that unlocks a token. + /// + /// In en, this message translates to: + /// **'Unlock'** + String get unlock; + + /// No tokens stored yet. + /// + /// In en, this message translates to: + /// **'No tokens stored yet.'** + String get noResultTitle; + + /// first no result text + /// + /// In en, this message translates to: + /// **'Tap the '** + String get noResultText1; + + /// second no result text + /// + /// In en, this message translates to: + /// **' button to get started!'** + String get noResultText2; + + /// No description provided for @onBoardingTitle1. + /// + /// In en, this message translates to: + /// **'{appName}'** + String onBoardingTitle1(Object appName); + + /// No description provided for @onBoardingText1. + /// + /// In en, this message translates to: + /// **'Two-factor authentication\nmade easy'** + String get onBoardingText1; + + /// No description provided for @onBoardingTitle2. + /// + /// In en, this message translates to: + /// **'Maximum Security'** + String get onBoardingTitle2; + + /// No description provided for @onBoardingText2. + /// + /// In en, this message translates to: + /// **'Store tokens on your device securely\nProtected by your biometrics'** + String get onBoardingText2; + + /// No description provided for @onBoardingTitle3. + /// + /// In en, this message translates to: + /// **'Visit us at Github'** + String get onBoardingTitle3; + + /// No description provided for @onBoardingText3. + /// + /// In en, this message translates to: + /// **'This app is open source'** + String get onBoardingText3; + + /// No description provided for @errorLogTitle. + /// + /// In en, this message translates to: + /// **'Error logs'** + String get errorLogTitle; + + /// Hint for the user about what he will send. + /// + /// In en, this message translates to: + /// **'Send us the error log via e-mail'** + String get sendErrorHint; + + /// No description provided for @enableVerboseLogging. + /// + /// In en, this message translates to: + /// **'Enable verbose logging'** + String get enableVerboseLogging; + + /// No description provided for @clearErrorLogHint. + /// + /// In en, this message translates to: + /// **'Clears the local error log file'** + String get clearErrorLogHint; + + /// No description provided for @logMenu. + /// + /// In en, this message translates to: + /// **'Log menu'** + String get logMenu; + + /// No description provided for @sendErrorDialogHeader. + /// + /// In en, this message translates to: + /// **'Send via e-mail'** + String get sendErrorDialogHeader; + + /// No description provided for @ok. + /// + /// In en, this message translates to: + /// **'Ok'** + String get ok; + + /// No description provided for @noLogToSend. + /// + /// In en, this message translates to: + /// **'There is log to send.'** + String get noLogToSend; + + /// Message for email body + /// + /// In en, this message translates to: + /// **'The error log file is attached.\nYou can replace this text with additional information about the error.'** + String get errorMailBody; + + /// No description provided for @errorLogCleared. + /// + /// In en, this message translates to: + /// **'Error logs cleared.'** + String get errorLogCleared; + + /// No description provided for @showDetails. + /// + /// In en, this message translates to: + /// **'Show details'** + String get showDetails; + + /// No description provided for @open. + /// + /// In en, this message translates to: + /// **'Open'** + String get open; + + /// Description shown to the user about what info the error report contains. + /// + /// In en, this message translates to: + /// **'An unexpected error occurred in the application. The information below can be send to the developers by email to help prevent this error in the future.'** + String get sendErrorDialogBody; + + /// No description provided for @noFbToken. + /// + /// In en, this message translates to: + /// **'No Firebase token available'** + String get noFbToken; + + /// No description provided for @firebaseToken. + /// + /// In en, this message translates to: + /// **'Firebase Token'** + String get firebaseToken; + + /// No description provided for @noPublicKey. + /// + /// In en, this message translates to: + /// **'No public key available'** + String get noPublicKey; + + /// No description provided for @publicKey. + /// + /// In en, this message translates to: + /// **'Public Key'** + String get publicKey; + + /// No description provided for @editToken. + /// + /// In en, this message translates to: + /// **'Edit Token'** + String get editToken; + + /// No description provided for @edit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @validFor. + /// + /// In en, this message translates to: + /// **'Valid for'** + String get validFor; + + /// No description provided for @validUntil. + /// + /// In en, this message translates to: + /// **'Valid until'** + String get validUntil; + + /// No description provided for @deleteLockedToken. + /// + /// In en, this message translates to: + /// **'Please authenticate to delete the locked token.'** + String get deleteLockedToken; + + /// No description provided for @editLockedToken. + /// + /// In en, this message translates to: + /// **'Please authenticate to edit the locked token.'** + String get editLockedToken; + + /// No description provided for @uncollapseLockedFolder. + /// + /// In en, this message translates to: + /// **'Please authenticate to uncollapse the locked folder.'** + String get uncollapseLockedFolder; + + /// Title of the dialog where a new name for a token folder can be entered. + /// + /// In en, this message translates to: + /// **'Rename folder'** + String get renameTokenFolder; + + /// No description provided for @addANewFolder. + /// + /// In en, this message translates to: + /// **'Create new folder'** + String get addANewFolder; + + /// No description provided for @folderName. + /// + /// In en, this message translates to: + /// **'Foldername'** + String get folderName; + + /// No description provided for @retryRollout. + /// + /// In en, this message translates to: + /// **'Retry rollout'** + String get retryRollout; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Generating RSA key pair'** + String get generatingRSAKeyPair; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Generating RSA key pair failed'** + String get generatingRSAKeyPairFailed; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Sending public RSA key'** + String get sendingRSAPublicKey; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Sending public RSA key failed'** + String get sendingRSAPublicKeyFailed; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Parsing response'** + String get parsingResponse; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Parsing response failed'** + String get parsingResponseFailed; + + /// Message for the rollout process + /// + /// In en, this message translates to: + /// **'Rollout completed'** + String get rolloutCompleted; + + /// No description provided for @authToAcceptPushRequest. + /// + /// In en, this message translates to: + /// **'Please authenticate to accept the push request.'** + String get authToAcceptPushRequest; + + /// No description provided for @authToDeclinePushRequest. + /// + /// In en, this message translates to: + /// **'Please authenticate to decline the push request.'** + String get authToDeclinePushRequest; + + /// No description provided for @incomingAuthRequestError. + /// + /// In en, this message translates to: + /// **'The message didn\'t provided the needed data or the data was malformed.'** + String get incomingAuthRequestError; + + /// No description provided for @imageUrl. + /// + /// In en, this message translates to: + /// **'Image URL'** + String get imageUrl; + + /// Tells the user that the roll-out failed because the SSL handshake failed. + /// + /// In en, this message translates to: + /// **'SSL handshake failed. Roll-out not possible.'** + String get errorRollOutSSLHandshakeFailed; + + /// errorWhenPullingChallenges + /// + /// In en, this message translates to: + /// **'An error occured when polling for challenges of {name}'** + String errorWhenPullingChallenges(Object name); + + /// Tells the user that the roll-out failed because the token has expired. + /// + /// In en, this message translates to: + /// **'Rolling out this Token is not possible anymore.\nThe token {name} has expired.'** + String errorRollOutTokenExpired(Object name); + + /// No description provided for @yes. + /// + /// In en, this message translates to: + /// **'Yes'** + String get yes; + + /// No description provided for @no. + /// + /// In en, this message translates to: + /// **'No'** + String get no; + + /// No description provided for @butDiscardIt. + /// + /// In en, this message translates to: + /// **'but discard it'** + String get butDiscardIt; + + /// No description provided for @declineIt. + /// + /// In en, this message translates to: + /// **'decline it'** + String get declineIt; + + /// No description provided for @requestTriggerdByUserQuestion. + /// + /// In en, this message translates to: + /// **'Was this request triggered by you?'** + String get requestTriggerdByUserQuestion; + + /// No description provided for @grantCameraPermissionDialogTitle. + /// + /// In en, this message translates to: + /// **'Camera permission is not granted'** + String get grantCameraPermissionDialogTitle; + + /// No description provided for @grantCameraPermissionDialogContent. + /// + /// In en, this message translates to: + /// **'Please grant camera permission to scan QR codes.'** + String get grantCameraPermissionDialogContent; + + /// No description provided for @grantCameraPermissionDialogPermanentlyDenied. + /// + /// In en, this message translates to: + /// **'Camera permission is permanently denied. Please grant camera permission in your Phone\'s settings.'** + String get grantCameraPermissionDialogPermanentlyDenied; + + /// No description provided for @grantCameraPermissionDialogButton. + /// + /// In en, this message translates to: + /// **'Grant permission'** + String get grantCameraPermissionDialogButton; + + /// No description provided for @decryptErrorTitle. + /// + /// In en, this message translates to: + /// **'Decryption error'** + String get decryptErrorTitle; + + /// No description provided for @decryptErrorContent. + /// + /// In en, this message translates to: + /// **'Unfortunately, the app was unable to decrypt your tokens. This indicates that the encryption key is broken. You can try again or delete the app data, which would delete the tokens in the app.'** + String get decryptErrorContent; + + /// No description provided for @decryptErrorButtonDelete. + /// + /// In en, this message translates to: + /// **'Delete'** + String get decryptErrorButtonDelete; + + /// No description provided for @decryptErrorButtonSendError. + /// + /// In en, this message translates to: + /// **'Send error'** + String get decryptErrorButtonSendError; + + /// No description provided for @decryptErrorButtonRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get decryptErrorButtonRetry; + + /// No description provided for @decryptErrorDeleteConfirmationContent. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete the app data?'** + String get decryptErrorDeleteConfirmationContent; + + /// No description provided for @hidePushTokens. + /// + /// In en, this message translates to: + /// **'Hide push tokens'** + String get hidePushTokens; + + /// No description provided for @hidePushTokensDescription. + /// + /// In en, this message translates to: + /// **'Hide push tokens from the token list. This will not delete the tokens and they will still be visible on a separate screen.'** + String get hidePushTokensDescription; + + /// No description provided for @licensesAndVersion. + /// + /// In en, this message translates to: + /// **'Licenses and version'** + String get licensesAndVersion; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['cs', 'de', 'en', 'es', 'fr', 'nl', 'pl'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + + + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'cs': return AppLocalizationsCs(); + case 'de': return AppLocalizationsDe(); + case 'en': return AppLocalizationsEn(); + case 'es': return AppLocalizationsEs(); + case 'fr': return AppLocalizationsFr(); + case 'nl': return AppLocalizationsNl(); + case 'pl': return AppLocalizationsPl(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.' + ); +} diff --git a/lib/l10n/app_localizations_cs.dart b/lib/l10n/app_localizations_cs.dart new file mode 100644 index 000000000..e2357d8df --- /dev/null +++ b/lib/l10n/app_localizations_cs.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for Czech (`cs`). +class AppLocalizationsCs extends AppLocalizations { + AppLocalizationsCs([String locale = 'cs']) : super(locale); + + @override + String get accept => 'Přijmout'; + + @override + String get decline => 'Odmítnout'; + + @override + String get name => 'Název'; + + @override + String get secret => 'Heslo'; + + @override + String get encoding => 'Kódování'; + + @override + String get algorithm => 'Algoritmus'; + + @override + String get digits => 'Počet číslic'; + + @override + String get type => 'Typ'; + + @override + String get period => 'Časový interval'; + + @override + String get rename => 'Přejmenovat'; + + @override + String get cancel => 'Zrušit'; + + @override + String get delete => 'Smazat'; + + @override + String get dismiss => 'Zavřít'; + + @override + String get addToken => 'Přidat token'; + + @override + String get scanQrCode => 'Naskenovat QR kód'; + + @override + String get enterDetailsForToken => 'Vložte podrobnosti tokenu'; + + @override + String get pleaseEnterANameForThisToken => 'Vložte název pro tento token.'; + + @override + String get pleaseEnterASecretForThisToken => 'Vložte tajný klíč pro tento token.'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'Tajný klíč neodpovídá zvolenému kódování.'; + + @override + String get renameToken => 'Přejmenovat token'; + + @override + String get confirmDeletion => 'Potvrdit smazání'; + + @override + String confirmDeletionOf(Object name) { + return 'Opravdu chcete smazat token $name?'; + } + + @override + String get generatingPhonePart => 'Generování klientské části'; + + @override + String get phonePart => 'Klientská část:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Heslo \"$otpValue\" bylo zkopírováno do schránky.'; + } + + @override + String get settings => 'Nastavení'; + + @override + String get pushToken => 'Push notifikace'; + + @override + String get theme => 'Vzhled'; + + @override + String get lightTheme => 'Světlý'; + + @override + String get darkTheme => 'Tmavý'; + + @override + String get systemTheme => 'Použít nastavení systému'; + + @override + String get enablePolling => 'Povolit polling'; + + @override + String get requestPushChallengesPeriodically => 'Periodicky získávat výzvy ze serveru. Povolte pokud nefunguje příjem push notifikací.'; + + @override + String get synchronizePushTokens => 'Synchronizace push tokenů'; + + @override + String get synchronizesTokensWithServer => 'Synchronizovat tokeny se serverem privacyIDEA.'; + + @override + String get sync => 'Synchronizovat'; + + @override + String get synchronizingTokens => 'Tokeny se synchronizují.'; + + @override + String get allTokensSynchronized => 'Všechny tokeny jsou synchronizované.'; + + @override + String get synchronizationFailed => 'Synchronizace následujících tokenů selhala, zkuste to znovu:'; + + @override + String get tokensDoNotSupportSynchronization => 'Následující tokeny nepodporují synchronizaci a musí být znovu zaregistrovány:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Registrace tokenu $name selhala. Kód chyby: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'Synchronizace tokenů selhala, připojení k serveru privacyIDEA se nezdařilo.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'Registrace tokenu $name selhala. Server není dostupný.'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'Vyskytla se neznámá chyba. Registrace není možná: $e'; + } + + @override + String get rollingOut => 'Registrace'; + + @override + String get pollingChallenges => 'Čekám na nové požadavky'; + + @override + String get unexpectedError => 'Nastala neočekávaná chyba.'; + + @override + String get pollingFailNoNetworkConnection => 'Stahování selhalo. Server není dostupný.'; + + @override + String get useDeviceLocaleTitle => 'Použít jazyk zařízení'; + + @override + String get useDeviceLocaleDescription => 'Použít jazyk zařízení, pokud je podporován, případně angličtinu.'; + + @override + String get language => 'Jazyk'; + + @override + String get authenticateToShowOtp => 'Pro zobrazení jednorázového kódu se přihlaste.'; + + @override + String get authenticateToUnLockToken => 'Pro změnu uzamčení tokenu se přihlaste.'; + + @override + String get biometricRequiredTitle => 'Biometrické ověření není nastaveno'; + + @override + String get biometricHint => 'Vyžadováno přihlášení'; + + @override + String get biometricNotRecognized => 'Ověření se nezdařilo. Zkuste to znovu.'; + + @override + String get biometricSuccess => 'Přihlášení bylo úspěšné'; + + @override + String get deviceCredentialsRequiredTitle => 'Není nastaven zámek zařízení'; + + @override + String get deviceCredentialsSetupDescription => 'Nastave zámek zařízení v nastavení zařízení'; + + @override + String get signInTitle => 'Vyžadováno přihlášení'; + + @override + String get goToSettingsButton => 'Otevřít nastavení'; + + @override + String get goToSettingsDescription => 'Není nastaveno přihlášení zámkem zařízení ani biometrické ověření. Aktivujte je v nastavení zařízení.'; + + @override + String get lockOut => 'Biometrické ověření je deaktivováno. Pro aktivaci zamkněte a znovu odemkněte obrazovku/zařízení.'; + + @override + String get authNotSupportedTitle => 'Vyžadován zámek zařízení nebo biometrické ověření'; + + @override + String get authNotSupportedBody => 'Tato akce vyžaduje, aby bylo zařízení chráněno zámkem zařízení nebo biometrickým ověřením.'; + + @override + String get lock => 'Zamknout'; + + @override + String get unlock => 'Odemknout'; + + @override + String get noResultTitle => 'Nejsou nainstalovány žádné tokeny.'; + + @override + String get noResultText1 => 'stiskněte tlačítko '; + + @override + String get noResultText2 => ' a začněte s používáním.'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'vícefázové ověření\nusnadněno'; + + @override + String get onBoardingTitle2 => 'Maximální Bezpečnost'; + + @override + String get onBoardingText2 => 'Uložte tokeny do svého zařízení\nchráněné biometrickým ověřením'; + + @override + String get onBoardingTitle3 => 'Navštivte náš profil Github'; + + @override + String get onBoardingText3 => 'Tuto aplikaci má open source'; + + @override + String get errorLogTitle => 'Protokoly o chybách'; + + @override + String get sendErrorHint => 'Pošlete nám protokol o chybě e-mailem'; + + @override + String get enableVerboseLogging => 'Povolit slovní protokolování'; + + @override + String get clearErrorLogHint => 'Vymaže místní soubor protokolu chyb'; + + @override + String get logMenu => 'Nabídka protokolu'; + + @override + String get sendErrorDialogHeader => 'Odeslat e-mailem'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'Je třeba odeslat protokol.'; + + @override + String get errorMailBody => 'Přiložen je soubor protokolu o chybách.\nTento text můžete nahradit dalšími informacemi o chybě.'; + + @override + String get errorLogCleared => 'Protokoly chyb byly vymazány.'; + + @override + String get showDetails => 'Zobrazit podrobnosti'; + + @override + String get open => 'Otevřít'; + + @override + String get sendErrorDialogBody => 'V aplikaci se vyskytla neznámá chyba. Informace uvedené níže mohou být odeslány vývojářům e-mailem pro vyřešení chyby v budoucnu.'; + + @override + String get noFbToken => 'Není k dispozici žádný token Firebase.'; + + @override + String get firebaseToken => 'Token Firebase'; + + @override + String get noPublicKey => 'Není k dispozici žádný veřejný klíč.'; + + @override + String get publicKey => 'Veřejný klíč'; + + @override + String get editToken => 'Upravit token'; + + @override + String get edit => 'Upravit'; + + @override + String get save => 'Uložit'; + + @override + String get validFor => 'Platné pro'; + + @override + String get validUntil => 'Platné do'; + + @override + String get deleteLockedToken => 'Prosím, autentifikujte se pro smazání uzamčeného tokenu.'; + + @override + String get editLockedToken => 'Prosím, autentifikujte se pro úpravu uzamčeného tokenu.'; + + @override + String get uncollapseLockedFolder => 'Chcete-li otevřít uzamčenou složku, ověřte se.'; + + @override + String get renameTokenFolder => 'Přejmenování složky'; + + @override + String get addANewFolder => 'Vytvoření nové složky'; + + @override + String get folderName => 'Název složky'; + + @override + String get retryRollout => 'Zkusit znovu'; + + @override + String get generatingRSAKeyPair => 'Generování párů klíčů RSA'; + + @override + String get generatingRSAKeyPairFailed => 'Generování páru klíčů RSA se nezdařilo'; + + @override + String get sendingRSAPublicKey => 'Odeslání veřejného klíče RSA'; + + @override + String get sendingRSAPublicKeyFailed => 'Nepodařilo se odeslat veřejný klíč RSA'; + + @override + String get parsingResponse => 'Rozbor odpovědi'; + + @override + String get parsingResponseFailed => 'Parsování odpovědi se nezdařilo'; + + @override + String get rolloutCompleted => 'Zavedení dokončeno'; + + @override + String get authToAcceptPushRequest => 'Pro přijetí požadavku na push notifikaci se přihlaste.'; + + @override + String get authToDeclinePushRequest => 'Pro odmítnutí požadavku na push notifikaci se přihlaste.'; + + @override + String get incomingAuthRequestError => 'Zpráva neposkytla potřebná data nebo byla data chybně formulována.'; + + @override + String get imageUrl => 'URL obrázku'; + + @override + String get errorRollOutSSLHandshakeFailed => 'SSL handshake se nezdařil. Roll-out není možný.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'Při dotazování na výzvy $name došlo k chybě.'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'Roll-out tohoto tokenu již není možný.\nPlatnost tokenu $name vypršela.'; + } + + @override + String get yes => 'Ano'; + + @override + String get no => 'Ne'; + + @override + String get butDiscardIt => 'ale zahodit jej'; + + @override + String get declineIt => 'odmítnout jej '; + + @override + String get requestTriggerdByUserQuestion => 'Byl tento požadavek vyvolán vámi?'; + + @override + String get grantCameraPermissionDialogTitle => 'Camera permission is not granted'; + + @override + String get grantCameraPermissionDialogContent => 'Please grant camera permission to scan QR codes.'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'Oprávnění kamery je trvale odepřeno. Udělte prosím oprávnění fotoaparátu v nastavení telefonu.'; + + @override + String get grantCameraPermissionDialogButton => 'Udělit oprávnění'; + + @override + String get decryptErrorTitle => 'Chyba dešifrování'; + + @override + String get decryptErrorContent => 'Bohužel se aplikaci nepodařilo dešifrovat vaše tokeny. To znamená, že šifrovací klíč je poškozen. Můžete to zkusit znovu nebo odstranit data aplikace, čímž by došlo k odstranění tokenů v aplikaci.'; + + @override + String get decryptErrorButtonDelete => 'Odstranit'; + + @override + String get decryptErrorButtonSendError => 'Odeslat chybu'; + + @override + String get decryptErrorButtonRetry => 'Opakování'; + + @override + String get decryptErrorDeleteConfirmationContent => 'Jste si jisti, že chcete data aplikace odstranit?'; + + @override + String get hidePushTokens => 'Skrýt push tokeny'; + + @override + String get hidePushTokensDescription => 'Skrýt push tokeny ze seznamu tokenů. Tím se tokeny neodstraní a budou stále viditelné na samostatné obrazovce.'; + + @override + String get licensesAndVersion => 'Licence a verze'; +} diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart new file mode 100644 index 000000000..1f6dd588b --- /dev/null +++ b/lib/l10n/app_localizations_de.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for German (`de`). +class AppLocalizationsDe extends AppLocalizations { + AppLocalizationsDe([String locale = 'de']) : super(locale); + + @override + String get accept => 'Akzeptieren'; + + @override + String get decline => 'Ablehnen'; + + @override + String get name => 'Name'; + + @override + String get secret => 'Geheimnis'; + + @override + String get encoding => 'Kodierung'; + + @override + String get algorithm => 'Algorithmus'; + + @override + String get digits => 'Ziffern'; + + @override + String get type => 'Art'; + + @override + String get period => 'Periode'; + + @override + String get rename => 'Umbenennen'; + + @override + String get cancel => 'Abbrechen'; + + @override + String get delete => 'Löschen'; + + @override + String get dismiss => 'Schließen'; + + @override + String get addToken => 'Token hinzufügen'; + + @override + String get scanQrCode => 'QR-Code scannen'; + + @override + String get enterDetailsForToken => 'Neuen Token konfigurieren'; + + @override + String get pleaseEnterANameForThisToken => 'Bitte geben Sie einen Namen ein.'; + + @override + String get pleaseEnterASecretForThisToken => 'Bitte geben Sie ein Geheimnis ein.'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'Das Geheimnis entspricht nicht der gewählten Verschlüsselung.'; + + @override + String get renameToken => 'Token umbenennen'; + + @override + String get confirmDeletion => 'Löschen bestätigen'; + + @override + String confirmDeletionOf(Object name) { + return 'Bestätigen Sie das Löschen von $name?'; + } + + @override + String get generatingPhonePart => 'Generiere Telefonanteil'; + + @override + String get phonePart => 'Telefonanteil:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Passwort \"$otpValue\" wurde in Zwischenablage kopiert.'; + } + + @override + String get settings => 'Einstellungen'; + + @override + String get pushToken => 'Push Token'; + + @override + String get theme => 'Farbschema'; + + @override + String get lightTheme => 'Hell'; + + @override + String get darkTheme => 'Dunkel'; + + @override + String get systemTheme => 'Nutze Farbschema des Geräts'; + + @override + String get enablePolling => 'Aktives Stellen von Push-Anfragen'; + + @override + String get requestPushChallengesPeriodically => 'Fordert regelmäßig Push-Anfragen vom Server an. Aktivieren Sie diese Funktion, wenn Nachrichten ansonsten nicht erhalten werden.'; + + @override + String get synchronizePushTokens => 'Synchronisiere Push Token'; + + @override + String get synchronizesTokensWithServer => 'Synchronisiert Token mit dem privacyIDEA Server.'; + + @override + String get sync => 'Sync'; + + @override + String get synchronizingTokens => 'Synchronisiere Token.'; + + @override + String get allTokensSynchronized => 'Alle Token wurden synchronisiert.'; + + @override + String get synchronizationFailed => 'Synchronisation ist für die folgenden Token fehlgeschlagen:'; + + @override + String get tokensDoNotSupportSynchronization => 'Die folgenden Token unterstützen keine Synchronisation und müssen erneut ausgerollt werden:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Ausrollen von $name ist fehlgeschlagen. Fehlercode: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'Die Synchronisation ist fehlgeschlagen, da der privacyIDEA Server nicht erreicht werden konnte.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'Der Rollout von Token $name ist fehlgeschlagen, der Server konnte nicht erreicht werden.'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'Ein unbekannter Fehler ist aufgetreten. Aurollen nicht möglich: $e'; + } + + @override + String get rollingOut => 'Ausrollen'; + + @override + String get pollingChallenges => 'Frage ausstehende Authentifizierungsanfragen ab'; + + @override + String get unexpectedError => 'Ein unerwarteter Fehler ist aufgetreten.'; + + @override + String get pollingFailNoNetworkConnection => 'Abfrage fehlgeschlagen, der Server ist nicht erreichbar.'; + + @override + String get useDeviceLocaleTitle => 'Nutze Systemsprache'; + + @override + String get useDeviceLocaleDescription => 'Nutze Systemsprache, falls diese unterstützt wird. Anderenfalls nutze Englisch. '; + + @override + String get language => 'Sprache'; + + @override + String get authenticateToShowOtp => 'Bitte authentifizieren Sie sich, um das Einmalpasswort anzuzeigen.'; + + @override + String get authenticateToUnLockToken => 'Bitte authentifizieren Sie sich, um den Sperrstatus des Tokens zu ändern.'; + + @override + String get biometricRequiredTitle => 'Biometrie ist nicht eingerichtet'; + + @override + String get biometricHint => 'Authentifizierung wird benötigt'; + + @override + String get biometricNotRecognized => 'Biometrie wurde nicht erkannt, bitte versuchen Sie es erneut'; + + @override + String get biometricSuccess => 'Authentifizierung erfolgreich'; + + @override + String get deviceCredentialsRequiredTitle => 'Gerätepasswort ist nicht eingerichtet'; + + @override + String get deviceCredentialsSetupDescription => 'Setzen Sie bitte ein Gerätepasswort in den Einstellungen'; + + @override + String get signInTitle => 'Authentifizierung wird benötigt'; + + @override + String get goToSettingsButton => 'Gehe zu Einstellungen'; + + @override + String get goToSettingsDescription => 'Authentifizierung durch Gerätepasswort oder Biometrie ist nicht eingerichtet. Bitte aktivieren Sie dies in den Geräteeinstellungen.'; + + @override + String get lockOut => 'Biometrie ist deaktiviert. Bitte sperren und entsperren Sie Ihren Bildschirm um diese zu aktivieren.'; + + @override + String get authNotSupportedTitle => 'Gerätepasswort oder Biometrie wird benötigt'; + + @override + String get authNotSupportedBody => 'Diese Aktion erfordert, dass auf dem Gerät ein Passwort oder Biometrie eingerichtet ist.'; + + @override + String get lock => 'Sperren'; + + @override + String get unlock => 'Entsperren'; + + @override + String get noResultTitle => 'Keine Token vorhanden.'; + + @override + String get noResultText1 => 'Tippe auf das '; + + @override + String get noResultText2 => ' Icon um loszulegen!'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'Zwei-Faktor-Authentifizierung\neinfach gemacht'; + + @override + String get onBoardingTitle2 => 'Maximale Sicherheit'; + + @override + String get onBoardingText2 => 'Speichern Sie Ihre Token sicher auf diesem Gerät\nGeschützt durch Ihre biometrischen Daten'; + + @override + String get onBoardingTitle3 => 'Besuchen Sie uns auf Github'; + + @override + String get onBoardingText3 => 'Diese App ist Open Source'; + + @override + String get errorLogTitle => 'Fehlerprotokolle'; + + @override + String get sendErrorHint => 'Senden Sie uns das Fehlerprotokoll per E-Mail'; + + @override + String get enableVerboseLogging => 'Fehler ausführlich protokollieren'; + + @override + String get clearErrorLogHint => 'Löscht die lokale Fehlerprotokolldatei'; + + @override + String get logMenu => 'Protokollmenu'; + + @override + String get sendErrorDialogHeader => 'Per E-Mail senden'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'Es gibt kein Protokoll zu senden.'; + + @override + String get errorMailBody => 'Die Fehlerprotokolldatei ist angehängt.\nSie können diesen Text durch zusätzliche Informationen über den Fehler ersetzen.'; + + @override + String get errorLogCleared => 'Fehlerprotokolle gelöscht'; + + @override + String get showDetails => 'Details anzeigen'; + + @override + String get open => 'Öffnen'; + + @override + String get sendErrorDialogBody => 'Ein unbekannter Fehler ist aufgetreten. Die unten gezeigten Informationen können den Entwicklern per E-Mail zugesendet werden, um zu helfen, diesen Fehler in Zukunft zu vermeiden.'; + + @override + String get noFbToken => 'Kein Firebase Token vorhanden'; + + @override + String get firebaseToken => 'Firebase Token'; + + @override + String get noPublicKey => 'Kein öffentlicher Schlüssel vorhanden'; + + @override + String get publicKey => 'Öffentlicher Schlüssel'; + + @override + String get editToken => 'Token bearbeiten'; + + @override + String get edit => 'Bearbeiten'; + + @override + String get save => 'Speichern'; + + @override + String get validFor => 'Gültig für'; + + @override + String get validUntil => 'Gültig bis'; + + @override + String get deleteLockedToken => 'Bitte authentifizieren Sie sich, um den gesperrten Token zu löschen.'; + + @override + String get editLockedToken => 'Bitte authentifizieren Sie sich, um den gesperrten Token zu bearbeiten.'; + + @override + String get uncollapseLockedFolder => 'Bitte authentifizieren Sie sich, um den gesperrten Ordner zu öffnen.'; + + @override + String get renameTokenFolder => 'Ordner umbenennen'; + + @override + String get addANewFolder => 'Neuen Ordner anlegen'; + + @override + String get folderName => 'Ordnername'; + + @override + String get retryRollout => 'Erneut ausrollen'; + + @override + String get generatingRSAKeyPair => 'Generiere RSA Schlüsselpaar'; + + @override + String get generatingRSAKeyPairFailed => 'Generieren des RSA Schlüsselpaars fehlgeschlagen'; + + @override + String get sendingRSAPublicKey => 'Sende öffentlichen RSA Schlüssel'; + + @override + String get sendingRSAPublicKeyFailed => 'Senden des öffentlichen RSA Schlüssels fehlgeschlagen'; + + @override + String get parsingResponse => 'Analysiere Antwort'; + + @override + String get parsingResponseFailed => 'Analysieren der Antwort fehlgeschlagen'; + + @override + String get rolloutCompleted => 'Ausrollen abgeschlossen'; + + @override + String get authToAcceptPushRequest => 'Bitte authentifizieren Sie sich, um die Anfrage anzunehmen.'; + + @override + String get authToDeclinePushRequest => 'Bitte authentifizieren Sie sich, um die Anfrage abzulehnen.'; + + @override + String get incomingAuthRequestError => 'Die Nachricht enthielt nicht die erforderlichen Daten oder die Daten waren falsch formatiert.'; + + @override + String get imageUrl => 'Bild URL'; + + @override + String get errorRollOutSSLHandshakeFailed => 'SSL-Handshake fehlgeschlagen. Roll-out nicht möglich.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'Fehler beim Abrufen der Authentifizierungsanfragen von $name'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'Das Ausrollen dieses Tokens ist nicht mehr möglich.\nDer Token $name ist abgelaufen.'; + } + + @override + String get yes => 'Ja'; + + @override + String get no => 'Nein'; + + @override + String get butDiscardIt => 'aber verwerfen'; + + @override + String get declineIt => 'ablehnen'; + + @override + String get requestTriggerdByUserQuestion => 'Wurde diese Anfrage von Ihnen ausgelöst?'; + + @override + String get grantCameraPermissionDialogTitle => 'Kamera-Berechtigung erforderlich'; + + @override + String get grantCameraPermissionDialogContent => 'Um QR-Codes zu scannen, benötigt die App Zugriff auf die Kamera.'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'Sie haben die Berechtigung für den Kamerazugriff permanent verweigert. Bitte aktivieren Sie die Berechtigung in den Einstellungen ihres Smartphones.'; + + @override + String get grantCameraPermissionDialogButton => 'Berechtigung erteilen'; + + @override + String get decryptErrorTitle => 'Entschlüsselung fehlgeschlagen'; + + @override + String get decryptErrorContent => 'Leider konnten Ihre Token nicht entschlüsselt werden. Das deutet darauf hin, dass der Verschlüsselungsschlüssel nicht mehr verfügbar ist. Sie können es erneut versuchen oder die App Daten löschen. Dabei werden alle Token aus der App geschlöscht.'; + + @override + String get decryptErrorButtonDelete => 'Löschen.'; + + @override + String get decryptErrorButtonSendError => 'Fehler senden'; + + @override + String get decryptErrorButtonRetry => 'Wiederholen'; + + @override + String get decryptErrorDeleteConfirmationContent => 'Sind Sie sicher, dass Sie die App Daten löschen möchten?'; + + @override + String get hidePushTokens => 'Push-Token ausblenden'; + + @override + String get hidePushTokensDescription => 'Push-Token aus der Token-Liste ausblenden. Dadurch werden die Token nicht gelöscht und sind weiterhin auf einem separaten Bildschirm sichtbar.'; + + @override + String get licensesAndVersion => 'Lizenzen und Version'; +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 000000000..a33128ead --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get accept => 'Accept'; + + @override + String get decline => 'Decline'; + + @override + String get name => 'Name'; + + @override + String get secret => 'Secret'; + + @override + String get encoding => 'Encoding'; + + @override + String get algorithm => 'Algorithm'; + + @override + String get digits => 'Digits'; + + @override + String get type => 'Type'; + + @override + String get period => 'Period'; + + @override + String get rename => 'Rename'; + + @override + String get cancel => 'Cancel'; + + @override + String get delete => 'Delete'; + + @override + String get dismiss => 'Dismiss'; + + @override + String get addToken => 'Add token'; + + @override + String get scanQrCode => 'Scan QR-Code'; + + @override + String get enterDetailsForToken => 'Enter details for token'; + + @override + String get pleaseEnterANameForThisToken => 'Please enter a name for this token.'; + + @override + String get pleaseEnterASecretForThisToken => 'Please enter a secret for this token.'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'The secret does not fit the current encoding'; + + @override + String get renameToken => 'Rename token'; + + @override + String get confirmDeletion => 'Confirm deletion'; + + @override + String confirmDeletionOf(Object name) { + return 'Are you sure you want to delete $name?'; + } + + @override + String get generatingPhonePart => 'Generating phone part'; + + @override + String get phonePart => 'Phone part:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Password \"$otpValue\" copied to clipboard.'; + } + + @override + String get settings => 'Settings'; + + @override + String get pushToken => 'Push Token'; + + @override + String get theme => 'Theme'; + + @override + String get lightTheme => 'Light'; + + @override + String get darkTheme => 'Dark'; + + @override + String get systemTheme => 'Use device\'s theme'; + + @override + String get enablePolling => 'Enable polling'; + + @override + String get requestPushChallengesPeriodically => 'Request push challenges from the server periodically. Enable this if push challenges are not received normally.'; + + @override + String get synchronizePushTokens => 'Synchronize push tokens'; + + @override + String get synchronizesTokensWithServer => 'Synchronizes tokens with the privacyIDEA server.'; + + @override + String get sync => 'Sync'; + + @override + String get synchronizingTokens => 'Synchronizing tokens.'; + + @override + String get allTokensSynchronized => 'All tokens are synchronized.'; + + @override + String get synchronizationFailed => 'Synchronization failed for the following tokens, please try again:'; + + @override + String get tokensDoNotSupportSynchronization => 'The following tokens do not support synchronization and must be rolled out again:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Rolling out token $name failed.\nError code: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'Synchronizing tokens failed, privacyIDEA server could not be reached.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'Rolling out token $name failed, the server could not be reached.'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'An unknown error occurred. Roll-out not possible: $e'; + } + + @override + String get rollingOut => 'Rolling out'; + + @override + String get pollingChallenges => 'Polling for new challenges'; + + @override + String get unexpectedError => 'An unexpected error occurred.'; + + @override + String get pollingFailNoNetworkConnection => 'Polling failed. Server cannot be reached.'; + + @override + String get useDeviceLocaleTitle => 'Use device language'; + + @override + String get useDeviceLocaleDescription => 'Use device language if it is supported, otherwise default to english.'; + + @override + String get language => 'Language'; + + @override + String get authenticateToShowOtp => 'Please authenticate to show one time password.'; + + @override + String get authenticateToUnLockToken => 'Please authenticate to change the lock status of the token.'; + + @override + String get biometricRequiredTitle => 'Biometrics not setup'; + + @override + String get biometricHint => 'Authentication required'; + + @override + String get biometricNotRecognized => 'Not recognized. Try again.'; + + @override + String get biometricSuccess => 'Authentication successful'; + + @override + String get deviceCredentialsRequiredTitle => 'Device credentials not set up'; + + @override + String get deviceCredentialsSetupDescription => 'Setup device credentials in the device\'s settings'; + + @override + String get signInTitle => 'Authentication required'; + + @override + String get goToSettingsButton => 'Go to settings'; + + @override + String get goToSettingsDescription => 'Authentication by credentials or biometrics is not set up on your device. Please set it up in the device\'s settings.'; + + @override + String get lockOut => 'Biometric authentication is disabled. Please lock and unlock your screen to enable it.'; + + @override + String get authNotSupportedTitle => 'Device credentials or biometrics required'; + + @override + String get authNotSupportedBody => 'This action requires the device to be secured by credentials or biometrics.'; + + @override + String get lock => 'Lock'; + + @override + String get unlock => 'Unlock'; + + @override + String get noResultTitle => 'No tokens stored yet.'; + + @override + String get noResultText1 => 'Tap the '; + + @override + String get noResultText2 => ' button to get started!'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'Two-factor authentication\nmade easy'; + + @override + String get onBoardingTitle2 => 'Maximum Security'; + + @override + String get onBoardingText2 => 'Store tokens on your device securely\nProtected by your biometrics'; + + @override + String get onBoardingTitle3 => 'Visit us at Github'; + + @override + String get onBoardingText3 => 'This app is open source'; + + @override + String get errorLogTitle => 'Error logs'; + + @override + String get sendErrorHint => 'Send us the error log via e-mail'; + + @override + String get enableVerboseLogging => 'Enable verbose logging'; + + @override + String get clearErrorLogHint => 'Clears the local error log file'; + + @override + String get logMenu => 'Log menu'; + + @override + String get sendErrorDialogHeader => 'Send via e-mail'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'There is log to send.'; + + @override + String get errorMailBody => 'The error log file is attached.\nYou can replace this text with additional information about the error.'; + + @override + String get errorLogCleared => 'Error logs cleared.'; + + @override + String get showDetails => 'Show details'; + + @override + String get open => 'Open'; + + @override + String get sendErrorDialogBody => 'An unexpected error occurred in the application. The information below can be send to the developers by email to help prevent this error in the future.'; + + @override + String get noFbToken => 'No Firebase token available'; + + @override + String get firebaseToken => 'Firebase Token'; + + @override + String get noPublicKey => 'No public key available'; + + @override + String get publicKey => 'Public Key'; + + @override + String get editToken => 'Edit Token'; + + @override + String get edit => 'Edit'; + + @override + String get save => 'Save'; + + @override + String get validFor => 'Valid for'; + + @override + String get validUntil => 'Valid until'; + + @override + String get deleteLockedToken => 'Please authenticate to delete the locked token.'; + + @override + String get editLockedToken => 'Please authenticate to edit the locked token.'; + + @override + String get uncollapseLockedFolder => 'Please authenticate to uncollapse the locked folder.'; + + @override + String get renameTokenFolder => 'Rename folder'; + + @override + String get addANewFolder => 'Create new folder'; + + @override + String get folderName => 'Foldername'; + + @override + String get retryRollout => 'Retry rollout'; + + @override + String get generatingRSAKeyPair => 'Generating RSA key pair'; + + @override + String get generatingRSAKeyPairFailed => 'Generating RSA key pair failed'; + + @override + String get sendingRSAPublicKey => 'Sending public RSA key'; + + @override + String get sendingRSAPublicKeyFailed => 'Sending public RSA key failed'; + + @override + String get parsingResponse => 'Parsing response'; + + @override + String get parsingResponseFailed => 'Parsing response failed'; + + @override + String get rolloutCompleted => 'Rollout completed'; + + @override + String get authToAcceptPushRequest => 'Please authenticate to accept the push request.'; + + @override + String get authToDeclinePushRequest => 'Please authenticate to decline the push request.'; + + @override + String get incomingAuthRequestError => 'The message didn\'t provided the needed data or the data was malformed.'; + + @override + String get imageUrl => 'Image URL'; + + @override + String get errorRollOutSSLHandshakeFailed => 'SSL handshake failed. Roll-out not possible.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'An error occured when polling for challenges of $name'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'Rolling out this Token is not possible anymore.\nThe token $name has expired.'; + } + + @override + String get yes => 'Yes'; + + @override + String get no => 'No'; + + @override + String get butDiscardIt => 'but discard it'; + + @override + String get declineIt => 'decline it'; + + @override + String get requestTriggerdByUserQuestion => 'Was this request triggered by you?'; + + @override + String get grantCameraPermissionDialogTitle => 'Camera permission is not granted'; + + @override + String get grantCameraPermissionDialogContent => 'Please grant camera permission to scan QR codes.'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'Camera permission is permanently denied. Please grant camera permission in your Phone\'s settings.'; + + @override + String get grantCameraPermissionDialogButton => 'Grant permission'; + + @override + String get decryptErrorTitle => 'Decryption error'; + + @override + String get decryptErrorContent => 'Unfortunately, the app was unable to decrypt your tokens. This indicates that the encryption key is broken. You can try again or delete the app data, which would delete the tokens in the app.'; + + @override + String get decryptErrorButtonDelete => 'Delete'; + + @override + String get decryptErrorButtonSendError => 'Send error'; + + @override + String get decryptErrorButtonRetry => 'Retry'; + + @override + String get decryptErrorDeleteConfirmationContent => 'Are you sure you want to delete the app data?'; + + @override + String get hidePushTokens => 'Hide push tokens'; + + @override + String get hidePushTokensDescription => 'Hide push tokens from the token list. This will not delete the tokens and they will still be visible on a separate screen.'; + + @override + String get licensesAndVersion => 'Licenses and version'; +} diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart new file mode 100644 index 000000000..41882a394 --- /dev/null +++ b/lib/l10n/app_localizations_es.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get accept => 'Aceptar'; + + @override + String get decline => 'Negar'; + + @override + String get name => 'Nombre'; + + @override + String get secret => 'Secreto'; + + @override + String get encoding => 'Codificación'; + + @override + String get algorithm => 'Algorithmo'; + + @override + String get digits => 'Dígitos'; + + @override + String get type => 'Tipo'; + + @override + String get period => 'Periodo'; + + @override + String get rename => 'Renombrar'; + + @override + String get cancel => 'Anular'; + + @override + String get delete => 'Borrar'; + + @override + String get dismiss => 'Desestimar'; + + @override + String get addToken => 'Añadir token'; + + @override + String get scanQrCode => 'Escanear código QR'; + + @override + String get enterDetailsForToken => 'Introduzca los datos de el token'; + + @override + String get pleaseEnterANameForThisToken => 'Introduzca un nombre para este token.'; + + @override + String get pleaseEnterASecretForThisToken => 'Por favor, introduzca un secreto para este token.'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'El secreto no se ajusta a la codificación actual'; + + @override + String get renameToken => 'Renombrar token'; + + @override + String get confirmDeletion => 'Confiem supresión'; + + @override + String confirmDeletionOf(Object name) { + return '¿Está seguro de que desea eliminar $name?'; + } + + @override + String get generatingPhonePart => 'Generar parte telefónico'; + + @override + String get phonePart => 'Pieza de teléfono:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Contraseña \"$otpValue\" copiada en el portapapeles.'; + } + + @override + String get settings => 'Configuración'; + + @override + String get pushToken => 'Push Token'; + + @override + String get theme => 'Tema'; + + @override + String get lightTheme => 'Luminoso'; + + @override + String get darkTheme => 'Negro'; + + @override + String get systemTheme => 'Utilizar el tema del teléfono'; + + @override + String get enablePolling => 'Activar polling'; + + @override + String get requestPushChallengesPeriodically => 'Solicita retos push al servidor periódicamente. Habilite esta opción si los retos push no se reciben normalmente.'; + + @override + String get synchronizePushTokens => 'Sinchronizar push tokens'; + + @override + String get synchronizesTokensWithServer => 'Sinchronizar tokens con el privacyIDEA servidor.'; + + @override + String get sync => 'Sinchronizar'; + + @override + String get synchronizingTokens => 'Sincronización de los tokens.'; + + @override + String get allTokensSynchronized => 'Todas los tokens están sincronizadas.'; + + @override + String get synchronizationFailed => 'La sincronización ha fallado para los siguientes tokens, por favor inténtelo de nuevo:'; + + @override + String get tokensDoNotSupportSynchronization => 'Las siguientes tokens no admiten la sincronización y deben volver a desplegarse:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Error en la extracción de el token $name. Código de error: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'Error al sincronizar los tokens. No se ha podido acceder al servidor de PrivacyIDEA.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'El despliegue del token $name ha fallado, no se ha podido acceder al servidor.'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'An unknown error occurred. Roll-out not possible: $e'; + } + + @override + String get rollingOut => 'Despliegue'; + + @override + String get pollingChallenges => 'Sondeo para nuevos challenges'; + + @override + String get unexpectedError => 'Se ha producido un error inesperado.'; + + @override + String get pollingFailNoNetworkConnection => 'Error de sondeo. No se puede acceder al servidor.'; + + @override + String get useDeviceLocaleTitle => 'Utiliza el idioma del teléfono'; + + @override + String get useDeviceLocaleDescription => 'Utilizar el idioma del dispositivo si está soportado, en caso contrario por defecto inglés.'; + + @override + String get language => 'Language'; + + @override + String get authenticateToShowOtp => 'Por favor, autentifíquese para mostrar la contraseña de una sola vez.'; + + @override + String get authenticateToUnLockToken => 'Por favor, autentifíquese para cambiar el estado de bloqueo del token.'; + + @override + String get biometricRequiredTitle => 'Biometría no configurada'; + + @override + String get biometricHint => 'AAutenticación necesaria'; + + @override + String get biometricNotRecognized => 'No reconocido. Inténtelo de nuevo.'; + + @override + String get biometricSuccess => 'Autenticación correcta'; + + @override + String get deviceCredentialsRequiredTitle => 'No se han configurado las credenciales del dispositivo.'; + + @override + String get deviceCredentialsSetupDescription => 'Configurar las credenciales del dispositivo en los ajustes del dispositivo'; + + @override + String get signInTitle => 'Autenticación necesaria'; + + @override + String get goToSettingsButton => 'Ir a la configuración'; + + @override + String get goToSettingsDescription => 'La autenticación por credenciales o biométrica no está configurada en tu dispositivo. Por favor, configúrala en los ajustes del dispositivo.'; + + @override + String get lockOut => 'La autenticación biométrica está desactivada. Bloquea y desbloquea la pantalla para activarla.'; + + @override + String get authNotSupportedTitle => 'Se requieren credenciales de dispositivo o datos biométricos'; + + @override + String get authNotSupportedBody => 'Esta acción requiere que el dispositivo esté protegido mediante credenciales o datos biométricos.'; + + @override + String get lock => 'Cierre'; + + @override + String get unlock => 'Desbloquear'; + + @override + String get noResultTitle => 'Aún no hay tokens almacenadas.'; + + @override + String get noResultText1 => 'Indique el '; + + @override + String get noResultText2 => ' para empezar.'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'Autenticación de dos factores\nmuy fácil'; + + @override + String get onBoardingTitle2 => 'Máxima seguridad'; + + @override + String get onBoardingText2 => 'Almacena tokens en tu teléfono/n de forma segura. Protegido por tus datos biométricos.'; + + @override + String get onBoardingTitle3 => 'Visítenos en Github'; + + @override + String get onBoardingText3 => 'Esta aplicación es de código abierto'; + + @override + String get errorLogTitle => 'Registros de errores'; + + @override + String get sendErrorHint => 'Envíanos el registro de errores por correo electrónico'; + + @override + String get enableVerboseLogging => 'Activar el registro detallado'; + + @override + String get clearErrorLogHint => 'Borra el archivo de registro de errores local'; + + @override + String get logMenu => 'Menú Registros'; + + @override + String get sendErrorDialogHeader => 'Enviar por correo electrónico'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'Hay log para enviar'; + + @override + String get errorMailBody => 'Se adjunta el archivo de registro de errores.\nPuede sustituir este texto por información adicional sobre el error.'; + + @override + String get errorLogCleared => 'Registros de error borrados'; + + @override + String get showDetails => 'Mostrar detalles'; + + @override + String get open => 'Abrir'; + + @override + String get sendErrorDialogBody => 'Se ha producido un error inesperado en la aplicación. La siguiente información puede ser enviada a los desarrolladores por correo electrónico para ayudar a prevenir este error en el futuro.'; + + @override + String get noFbToken => 'No hay token de Firebase.'; + + @override + String get firebaseToken => 'Token de Firebase'; + + @override + String get noPublicKey => 'No hay clave pública.'; + + @override + String get publicKey => 'Clave pública'; + + @override + String get editToken => 'Editar token'; + + @override + String get edit => 'Editar'; + + @override + String get save => 'Guardar'; + + @override + String get validFor => 'Válido para'; + + @override + String get validUntil => 'Válido hasta'; + + @override + String get deleteLockedToken => 'Por favor, autentíquese para eliminar el token bloqueado.'; + + @override + String get editLockedToken => 'Por favor, autentíquese para editar el token bloqueado.'; + + @override + String get uncollapseLockedFolder => 'Por favor, autentifíquese para abrir la carpeta bloqueada.'; + + @override + String get renameTokenFolder => 'Cambiar nombre de carpeta'; + + @override + String get addANewFolder => 'Crear nueva carpeta'; + + @override + String get folderName => 'Nombre de la carpeta'; + + @override + String get retryRollout => 'Reintentar despliegue'; + + @override + String get generatingRSAKeyPair => 'Generando par de claves RSA'; + + @override + String get generatingRSAKeyPairFailed => 'Error al generar el par de claves RSA'; + + @override + String get sendingRSAPublicKey => 'Enviando clave pública RSA'; + + @override + String get sendingRSAPublicKeyFailed => 'Error al enviar la clave pública RSA'; + + @override + String get parsingResponse => 'Analizando la respuesta'; + + @override + String get parsingResponseFailed => 'Error al analizar la respuesta'; + + @override + String get rolloutCompleted => 'Despliegue completado'; + + @override + String get authToAcceptPushRequest => 'Por favor, autentifíquese para aceptar la solicitud push.'; + + @override + String get authToDeclinePushRequest => 'Por favor, autentifíquese para rechazar la solicitud push.'; + + @override + String get incomingAuthRequestError => 'El mensaje no proporcionaba los datos necesarios o los datos estaban malformados.'; + + @override + String get imageUrl => 'URL de la imagen'; + + @override + String get errorRollOutSSLHandshakeFailed => 'Ha fallado el protocolo SSL. No es posible el despliegue.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'Se ha producido un error al buscar retos de $name'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'El despliegue de este token ya no es posible.\nEl token $name ha caducado.'; + } + + @override + String get yes => 'Sí'; + + @override + String get no => 'No'; + + @override + String get butDiscardIt => 'pero descártelo'; + + @override + String get declineIt => 'rechazar'; + + @override + String get requestTriggerdByUserQuestion => '¿Fue usted quien provocó esta petición?'; + + @override + String get grantCameraPermissionDialogTitle => 'El permiso de cámara no está concedido'; + + @override + String get grantCameraPermissionDialogContent => 'Por favor, concede permiso a la cámara para escanear códigos QR'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'El permiso de cámara está denegado permanentemente. Concede el permiso de cámara en la configuración del teléfono'; + + @override + String get grantCameraPermissionDialogButton => 'Conceder permiso'; + + @override + String get decryptErrorTitle => 'Error de descifrado'; + + @override + String get decryptErrorContent => 'Lamentablemente, la aplicación no ha podido descifrar tus tokens. Esto indica que la clave de cifrado está rota. Puedes volver a intentarlo o borrar los datos de la app, lo que eliminaría los tokens de la app.'; + + @override + String get decryptErrorButtonDelete => 'Borrar'; + + @override + String get decryptErrorButtonSendError => 'Enviar error'; + + @override + String get decryptErrorButtonRetry => 'Reintentar'; + + @override + String get decryptErrorDeleteConfirmationContent => '¿Estás seguro de que quieres borrar los datos de la aplicación?'; + + @override + String get hidePushTokens => 'Ocultar tokens push'; + + @override + String get hidePushTokensDescription => 'Ocultar tokens push de la lista de tokens. Esto no borrará los tokens y seguirán siendo visibles en una pantalla aparte'; + + @override + String get licensesAndVersion => 'Licencias y versión'; +} diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart new file mode 100644 index 000000000..3412155d3 --- /dev/null +++ b/lib/l10n/app_localizations_fr.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for French (`fr`). +class AppLocalizationsFr extends AppLocalizations { + AppLocalizationsFr([String locale = 'fr']) : super(locale); + + @override + String get accept => 'Accepter'; + + @override + String get decline => 'Refuser'; + + @override + String get name => 'Nom'; + + @override + String get secret => 'Secret'; + + @override + String get encoding => 'Encodage'; + + @override + String get algorithm => 'Algorithme'; + + @override + String get digits => 'Chiffres'; + + @override + String get type => 'Type'; + + @override + String get period => 'Période'; + + @override + String get rename => 'Renommer'; + + @override + String get cancel => 'Annuler'; + + @override + String get delete => 'Supprimer'; + + @override + String get dismiss => 'Fermer'; + + @override + String get addToken => 'Ajouter un jeton'; + + @override + String get scanQrCode => 'Numériser QR-Code'; + + @override + String get enterDetailsForToken => 'Saisissez les détails du jeton'; + + @override + String get pleaseEnterANameForThisToken => 'Veuillez saisir un nom pour ce jeton.'; + + @override + String get pleaseEnterASecretForThisToken => 'Veuillez saisir un secret pour ce jeton.'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'Le secret n\'est pas compatible avec \nl\'encodage actuel.'; + + @override + String get renameToken => 'Renommer jeton'; + + @override + String get confirmDeletion => 'Confirmer suppression'; + + @override + String confirmDeletionOf(Object name) { + return 'Confirmer la suppression de $name?'; + } + + @override + String get generatingPhonePart => 'Générer la part du téléphone'; + + @override + String get phonePart => 'Part du téléphone:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Le mot de passe \"$otpValue\" a été copié dans le presse-papier.'; + } + + @override + String get settings => 'Paramètres'; + + @override + String get pushToken => 'Jeton de type Push'; + + @override + String get theme => 'Thème'; + + @override + String get lightTheme => 'Thème lumineux'; + + @override + String get darkTheme => 'Thème sombre'; + + @override + String get systemTheme => 'Utiliser le thème de l\'appareil'; + + @override + String get enablePolling => 'Activer l\'interrogation du serveur.'; + + @override + String get requestPushChallengesPeriodically => 'Demander des challenges push depuis le serveur périodiquement. Activer cette fonction si les challenges push ne sont pas reçus normalement.'; + + @override + String get synchronizePushTokens => 'Synchoniser les jetons Push'; + + @override + String get synchronizesTokensWithServer => 'Synchroniser les jetons Push avec le serveur privacyIDEA.'; + + @override + String get sync => 'Synchroniser'; + + @override + String get synchronizingTokens => 'Synchroniser les jetons.'; + + @override + String get allTokensSynchronized => 'Tous les jetons ont été synchronisés.'; + + @override + String get synchronizationFailed => 'La synchronisation a échoué pour ces jetons, veuillez reéssayer:'; + + @override + String get tokensDoNotSupportSynchronization => 'Ces jetons ne supportent pas la synchronisation et doivent être de nouveau générés:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Le déploiement du jeton $name a échoué. Erreur réseau: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'La synchronization a échoué car le serveur est injoignable.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'El despliegue del token $name ha fallado, no se ha podido acceder al servidor.'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'Le déploiement a échoué suite à une erreur inconnue: $e'; + } + + @override + String get rollingOut => 'Déploiement en cours'; + + @override + String get pollingChallenges => 'Vérification de nouveaux challenges'; + + @override + String get unexpectedError => 'Une erreur inattendue s\'est produite.'; + + @override + String get pollingFailNoNetworkConnection => 'L\'interrogation a échoué. Le serveur est injoignable.'; + + @override + String get useDeviceLocaleTitle => 'Utiliser la langue de l\'appareil'; + + @override + String get useDeviceLocaleDescription => 'Utilisez la langue de l\'appareil si elle est prise en charge, sinon l\'anglais par défaut.'; + + @override + String get language => 'Langue'; + + @override + String get authenticateToShowOtp => 'Veuillez vous authentifier pour afficher un mot de passe à usage unique.'; + + @override + String get authenticateToUnLockToken => 'Veuillez vous authentifier pour modifier l\'état de verrouillage du jeton.'; + + @override + String get biometricRequiredTitle => 'La biométrie n\'est pas configurée'; + + @override + String get biometricHint => 'Authentification requise'; + + @override + String get biometricNotRecognized => 'Pas reconnu. Réessayer.'; + + @override + String get biometricSuccess => 'Authentification réussie'; + + @override + String get deviceCredentialsRequiredTitle => 'Les informations d\'identification de l\'appareil ne sont pas configurées'; + + @override + String get deviceCredentialsSetupDescription => 'Configurer les informations d\'identification de l\'appareil dans les paramètres de l\'appareil'; + + @override + String get signInTitle => 'Authentification requise'; + + @override + String get goToSettingsButton => 'Aller aux paramètres'; + + @override + String get goToSettingsDescription => 'L\'authentification par identifiants ou biométrie n\'est pas configurée sur votre appareil. Veuillez le configurer dans les paramètres de l\'appareil.'; + + @override + String get lockOut => 'L\'authentification biométrique est désactivée. Veuillez verrouiller et déverrouiller votre écran pour l\'activer.'; + + @override + String get authNotSupportedTitle => 'Informations d\'identification de l\'appareil ou données biométriques requises'; + + @override + String get authNotSupportedBody => 'Cette action nécessite que l\'appareil soit sécurisé par des identifiants ou des données biométriques.'; + + @override + String get lock => 'Bloquer'; + + @override + String get unlock => 'Ouvrir'; + + @override + String get noResultTitle => 'Aucun jeton n\'est encore stocké.'; + + @override + String get noResultText1 => 'Appuyez sur le \n'; + + @override + String get noResultText2 => 'bouton pour commencer!'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'Authentification à deux facteurs\nrendue facile'; + + @override + String get onBoardingTitle2 => 'Sécurité Maximale'; + + @override + String get onBoardingText2 => 'Stockez les jetons sur votre \nappareil en toute sécurité\nProtégé par votre biométrie'; + + @override + String get onBoardingTitle3 => 'Rendez-nous visite sur Github'; + + @override + String get onBoardingText3 => 'Cette application est open source'; + + @override + String get errorLogTitle => 'Journaux d\'erreurs'; + + @override + String get sendErrorHint => 'Envoyez-nous le journal des erreurs par courrier électronique'; + + @override + String get enableVerboseLogging => 'Activer la journalisation verbeuse'; + + @override + String get clearErrorLogHint => 'Efface le fichier journal des erreurs locales'; + + @override + String get logMenu => 'Menu journal'; + + @override + String get sendErrorDialogHeader => 'Envoyer par e-mail'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'Il y a un journal à envoyer'; + + @override + String get errorMailBody => 'Le fichier journal des erreurs est joint.\nVous pouvez remplacer ce texte par des informations supplémentaires sur l\'erreur.'; + + @override + String get errorLogCleared => 'Journaux d\'erreur effacés'; + + @override + String get showDetails => 'Afficher les détails'; + + @override + String get open => 'Ouvrir'; + + @override + String get sendErrorDialogBody => 'Une erreur inattendue est survenue dans l\'application. L\'information suivante peut être transmise aux développeurs par email afin d\'aider à corriger cette erreur dans le futur.'; + + @override + String get noFbToken => 'Pas de jeton Firebase'; + + @override + String get firebaseToken => 'Jeton Firebase'; + + @override + String get noPublicKey => 'Pas de clé publique'; + + @override + String get publicKey => 'Clé publique'; + + @override + String get editToken => 'Modifier le jeton'; + + @override + String get edit => 'Modifier'; + + @override + String get save => 'Enregistrer'; + + @override + String get validFor => 'Valide pour'; + + @override + String get validUntil => 'Valide jusqu\'à'; + + @override + String get deleteLockedToken => 'Veuillez vous authentifier pour supprimer le jeton verrouillé.'; + + @override + String get editLockedToken => 'Veuillez vous authentifier pour modifier le jeton verrouillé.'; + + @override + String get uncollapseLockedFolder => 'Veuillez vous authentifier pour ouvrir le dossier verrouillé.'; + + @override + String get renameTokenFolder => 'Renommer le dossier'; + + @override + String get addANewFolder => 'Créer un nouveau dossier'; + + @override + String get folderName => 'Nom du dossier'; + + @override + String get retryRollout => 'Réessayer le déploiement'; + + @override + String get generatingRSAKeyPair => 'Génération de la paire de clés RSA'; + + @override + String get generatingRSAKeyPairFailed => 'La génération de la paire de clés RSA a échoué'; + + @override + String get sendingRSAPublicKey => 'Envoi de la clé publique RSA'; + + @override + String get sendingRSAPublicKeyFailed => 'L\'envoi de la clé publique RSA a échoué'; + + @override + String get parsingResponse => 'Analyse de la réponse'; + + @override + String get parsingResponseFailed => 'L\'analyse de la réponse a échoué'; + + @override + String get rolloutCompleted => 'Déploiement terminé'; + + @override + String get authToAcceptPushRequest => 'Veuillez vous authentifier pour accepter la demande de connexion.'; + + @override + String get authToDeclinePushRequest => 'Veuillez vous authentifier pour refuser la demande de connexion.'; + + @override + String get incomingAuthRequestError => 'Le message ne contenait pas les données nécessaires ou les données étaient mal formées.'; + + @override + String get imageUrl => 'URL de l\'image'; + + @override + String get errorRollOutSSLHandshakeFailed => 'Échec de la prise de contact SSL. Le déploiement n\'est pas possible.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'Une erreur s\'est produite lors de l\'interrogation des défis de $name'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'Le déploiement de ce jeton n\'est plus possible. Le jeton $name a expiré.'; + } + + @override + String get yes => 'Oui'; + + @override + String get no => 'Non'; + + @override + String get butDiscardIt => 'mais l\'écarter'; + + @override + String get declineIt => 'refuser'; + + @override + String get requestTriggerdByUserQuestion => 'Cette demande a-t-elle été déclenchée par vous ?'; + + @override + String get grantCameraPermissionDialogTitle => 'L\'autorisation de la caméra n\'est pas accordée'; + + @override + String get grantCameraPermissionDialogContent => 'Veuillez accorder à la caméra l\'autorisation de scanner les codes QR'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'L\'autorisation de l\'appareil photo est refusée de manière permanente. Veuillez accorder l\'autorisation à l\'appareil photo dans les paramètres de votre téléphone.'; + + @override + String get grantCameraPermissionDialogButton => 'Accorder l\'autorisation'; + + @override + String get decryptErrorTitle => 'Erreur de décryptage'; + + @override + String get decryptErrorContent => 'Malheureusement, l\'application n\'a pas pu décrypter vos jetons. Cela indique que la clé de cryptage est cassée. Vous pouvez réessayer ou supprimer les données de l\'application, ce qui supprimera les jetons dans l\'application.'; + + @override + String get decryptErrorButtonDelete => 'Supprimer'; + + @override + String get decryptErrorButtonSendError => 'Erreur d\'envoi'; + + @override + String get decryptErrorButtonRetry => 'Réessayer'; + + @override + String get decryptErrorDeleteConfirmationContent => 'Êtes-vous sûr de vouloir supprimer les données de l\'application ?'; + + @override + String get hidePushTokens => 'Hide push tokens'; + + @override + String get hidePushTokensDescription => 'Masquer les jetons de poussée de la liste des jetons. Cela ne supprimera pas les jetons et ils seront toujours visibles sur un écran séparé'; + + @override + String get licensesAndVersion => 'Licences et version'; +} diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart new file mode 100644 index 000000000..be762b1fd --- /dev/null +++ b/lib/l10n/app_localizations_nl.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for Dutch Flemish (`nl`). +class AppLocalizationsNl extends AppLocalizations { + AppLocalizationsNl([String locale = 'nl']) : super(locale); + + @override + String get accept => 'Accepteren'; + + @override + String get decline => 'Weigeren'; + + @override + String get name => 'Naam'; + + @override + String get secret => 'Geheim'; + + @override + String get encoding => 'Codering'; + + @override + String get algorithm => 'Algoritme'; + + @override + String get digits => 'Cijfers'; + + @override + String get type => 'Type'; + + @override + String get period => 'Duur'; + + @override + String get rename => 'Wijzigen'; + + @override + String get cancel => 'Annuleren'; + + @override + String get delete => 'Verwijderen'; + + @override + String get dismiss => 'Sluiten'; + + @override + String get addToken => 'Token toevoegen'; + + @override + String get scanQrCode => 'Scan QR-Code'; + + @override + String get enterDetailsForToken => 'Voer informatie over token in'; + + @override + String get pleaseEnterANameForThisToken => 'Voer de naam in voor deze token.'; + + @override + String get pleaseEnterASecretForThisToken => 'Voer de geheime sleutel in voor deze token.'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'De geheime sleutel past niet bij de huidige codering'; + + @override + String get renameToken => 'Hernoem token'; + + @override + String get confirmDeletion => 'Bevestig verwijderen'; + + @override + String confirmDeletionOf(Object name) { + return 'Weet u zeker dat u $name wilt verwijderen?'; + } + + @override + String get generatingPhonePart => 'Genereren telefoon gedeelte'; + + @override + String get phonePart => 'Telefoon gedeelte:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Wachtwoord \"$otpValue\" gekopieerd naar het klembord.'; + } + + @override + String get settings => 'Instellingen'; + + @override + String get pushToken => 'Push Token'; + + @override + String get theme => 'Thema'; + + @override + String get lightTheme => 'Licht'; + + @override + String get darkTheme => 'Donker'; + + @override + String get systemTheme => 'Gebruik thema van het apparaat'; + + @override + String get enablePolling => 'Zoeken aanzetten'; + + @override + String get requestPushChallengesPeriodically => 'Activeer het zoeken naar berichten. Gebruik deze optie wanneer de push berichten niet worden ontvangen.'; + + @override + String get synchronizePushTokens => 'Synchroniseer push tokens'; + + @override + String get synchronizesTokensWithServer => 'Synchroniseert tokens met de privacyIDEA server.'; + + @override + String get sync => 'Synchroniseer'; + + @override + String get synchronizingTokens => 'Tokens synchroniseren.'; + + @override + String get allTokensSynchronized => 'Alle tokens zijn gesynchroniseerd.'; + + @override + String get synchronizationFailed => 'Synchroniseren mislukt voor de volgende tokens, probeer het opnieuw:'; + + @override + String get tokensDoNotSupportSynchronization => 'Voor de volgende tokens wordt synchroniseren niet ondersteunt, ze moeten opnieuw worden aangeleverd:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Uitrollen van token $name mislukt. Fout code: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'Token synchroniseren mislukt, privacyIDEA server kan niet worden bereikt.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'Uitrollen mislukt. Geen verbinding met de server.'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'Een onbekende fout heeft plaats gevonden. Uitrollen is niet mogelijk: $e'; + } + + @override + String get rollingOut => 'Uitrollen'; + + @override + String get pollingChallenges => 'Zoeken naar nieuwe aanvragen'; + + @override + String get unexpectedError => 'Er is een onverwachte fout opgetreden.'; + + @override + String get pollingFailNoNetworkConnection => 'Zoeken naar berichten mislukt. Server kan niet worden bereikt.'; + + @override + String get useDeviceLocaleTitle => 'Gebruik de taal van het apparaat'; + + @override + String get useDeviceLocaleDescription => 'Gebruik de taal van het apparaat wanneer het wordt ondersteund, val anders terug op Engels.'; + + @override + String get language => 'Taal'; + + @override + String get authenticateToShowOtp => 'Authenticeer om het eenmalige wachtwoord te tonen.'; + + @override + String get authenticateToUnLockToken => 'Authenticeer om de vergrendeling van de token te wijzigen.'; + + @override + String get biometricRequiredTitle => 'Biometrie is niet ingesteld'; + + @override + String get biometricHint => 'Authenticatie vereist'; + + @override + String get biometricNotRecognized => 'Niet herkend. Probeer opnieuw.'; + + @override + String get biometricSuccess => 'Authenticatie geslaagd'; + + @override + String get deviceCredentialsRequiredTitle => 'Inloggevens van het apparaat zijn niet ingesteld'; + + @override + String get deviceCredentialsSetupDescription => 'Stel de inloggegevens in, bij de instellingen van het apparaat'; + + @override + String get signInTitle => 'Authenticatie vereist'; + + @override + String get goToSettingsButton => 'Ga naar instellingen'; + + @override + String get goToSettingsDescription => 'Authenticatie via inloggegevens of biometrie is niet ingesteld. Stel het in bij de instellingen van het apparaat.'; + + @override + String get lockOut => 'Biometrische authenticatie staat uit. Vergrendel en ontgrendel het scherm om het aan te zetten.'; + + @override + String get authNotSupportedTitle => 'Apparaat inloggevens of biometrie is vereist'; + + @override + String get authNotSupportedBody => 'Deze actie vereist dat het apparaat is beveiligd met inlogggevens of biometrie.'; + + @override + String get lock => 'Vergrendel'; + + @override + String get unlock => 'Ontgrendel'; + + @override + String get noResultTitle => 'Nog geen token opgeslagen.'; + + @override + String get noResultText1 => 'Tik op '; + + @override + String get noResultText2 => ' de knop om te beginnen!'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'Twee-factoren authenticatie\nmakkelijk gemaakt'; + + @override + String get onBoardingTitle2 => 'Maximale Beveiliging'; + + @override + String get onBoardingText2 => 'Bewaar tokens op uw apparaat\nbeveiligd door uw biometrische gegevens'; + + @override + String get onBoardingTitle3 => 'Bezoek ons op Github'; + + @override + String get onBoardingText3 => 'Deze app is open source'; + + @override + String get errorLogTitle => 'Foutlogs'; + + @override + String get sendErrorHint => 'Stuur ons de error log via e-mail'; + + @override + String get enableVerboseLogging => 'Uitgebreide logboekregistratie inschakelen'; + + @override + String get clearErrorLogHint => 'Wist het lokale foutenlogbestand'; + + @override + String get logMenu => 'Logmenu'; + + @override + String get sendErrorDialogHeader => 'Verzenden via e-mail'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'Er is een log om te verzenden.'; + + @override + String get errorMailBody => 'Het foutlogbestand is bijgevoegd.\nU kunt deze tekst vervangen door aanvullende informatie over de fout.'; + + @override + String get errorLogCleared => 'Foutlogs gewist'; + + @override + String get showDetails => 'Details tonen'; + + @override + String get open => 'Openen'; + + @override + String get sendErrorDialogBody => 'Een onverwachte fout heeft plaatsgevonden in de applicatie. De onderstaande informatie kan worden verstuurd naar de ontwikkelaars via e-mail om het probleem in de toekomst te voorkomen.'; + + @override + String get noFbToken => 'Geen Firebase Token beschikbaar'; + + @override + String get firebaseToken => 'Firebase Token'; + + @override + String get noPublicKey => 'Geen openbare sleutel beschikbaar'; + + @override + String get publicKey => 'Openbare sleutel'; + + @override + String get editToken => 'Token bewerken'; + + @override + String get edit => 'Bewerken'; + + @override + String get save => 'Opslaan'; + + @override + String get validFor => 'Geldig voor'; + + @override + String get validUntil => 'Geldig tot'; + + @override + String get deleteLockedToken => 'Verifieer om het vergrendelde token te verwijderen.'; + + @override + String get editLockedToken => 'Verifieer om het vergrendelde token te bewerken.'; + + @override + String get uncollapseLockedFolder => 'Verifieer om de vergrendelde map te openen.'; + + @override + String get renameTokenFolder => 'Map hernoemen'; + + @override + String get addANewFolder => 'Nieuwe map maken'; + + @override + String get folderName => 'Mapnaam'; + + @override + String get retryRollout => 'Opnieuw uitrollen'; + + @override + String get generatingRSAKeyPair => 'Genereren RSA sleutelpaar'; + + @override + String get generatingRSAKeyPairFailed => 'Genereren RSA sleutelpaar mislukt'; + + @override + String get sendingRSAPublicKey => 'Versturen van de openbare RSA sleutel'; + + @override + String get sendingRSAPublicKeyFailed => 'Versturen van de openbare RSA sleutel mislukt'; + + @override + String get parsingResponse => 'Antwoord analyseren'; + + @override + String get parsingResponseFailed => 'Antwoord analyseren mislukt'; + + @override + String get rolloutCompleted => 'Uitrollen voltooid'; + + @override + String get authToAcceptPushRequest => 'Authenticeer om de push aanvraag te accepteren.'; + + @override + String get authToDeclinePushRequest => 'Authenticeer om de push aanvraag te weigeren.'; + + @override + String get incomingAuthRequestError => 'Het bericht bevatte niet de benodigde gegevens of de gegevens waren misvormd.'; + + @override + String get imageUrl => 'Afbeeldings-URL'; + + @override + String get errorRollOutSSLHandshakeFailed => 'SSL-handdruk mislukt. Uitrollen niet mogelijk.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'Er is een fout opgetreden bij het zoeken naar uitdagingen van $name'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'Het uitrollen van dit token is niet meer mogelijk.\nHet token $name is verlopen.'; + } + + @override + String get yes => 'Ja'; + + @override + String get no => 'Nee'; + + @override + String get butDiscardIt => 'maar verwijder'; + + @override + String get declineIt => 'weigeren'; + + @override + String get requestTriggerdByUserQuestion => 'Is dit verzoek door jou gedaan?'; + + @override + String get grantCameraPermissionDialogTitle => 'Cameratoestemming is niet verleend'; + + @override + String get grantCameraPermissionDialogContent => 'Geef de camera toestemming om QR-codes te scannen.'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'Cameratoestemming is permanent geweigerd. Geef de camera toestemming in de instellingen van uw telefoon.'; + + @override + String get grantCameraPermissionDialogButton => 'Toestemming verlenen'; + + @override + String get decryptErrorTitle => 'Fout bij decoderen'; + + @override + String get decryptErrorContent => 'Helaas heeft de app je tokens niet kunnen decoderen. Dit geeft aan dat de coderingssleutel is verbroken. U kunt het opnieuw proberen of de app-gegevens verwijderen, waardoor de tokens in de app worden verwijderd.'; + + @override + String get decryptErrorButtonDelete => 'Verwijderen'; + + @override + String get decryptErrorButtonSendError => 'Fout verzenden'; + + @override + String get decryptErrorButtonRetry => 'Opnieuw proberen'; + + @override + String get decryptErrorDeleteConfirmationContent => 'Weet je zeker dat je de app-gegevens wilt verwijderen?'; + + @override + String get hidePushTokens => 'Verberg push tokens'; + + @override + String get hidePushTokensDescription => 'Verberg push tokens uit de token lijst. Hierdoor worden de tokens niet verwijderd en blijven ze zichtbaar op een apart scherm.'; + + @override + String get licensesAndVersion => 'Licenties en versie'; +} diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart new file mode 100644 index 000000000..a59cdc2a3 --- /dev/null +++ b/lib/l10n/app_localizations_pl.dart @@ -0,0 +1,433 @@ +import 'app_localizations.dart'; + +/// The translations for Polish (`pl`). +class AppLocalizationsPl extends AppLocalizations { + AppLocalizationsPl([String locale = 'pl']) : super(locale); + + @override + String get accept => 'Potwierdzam'; + + @override + String get decline => 'Odrzucam'; + + @override + String get name => 'Nazwa'; + + @override + String get secret => 'Sekret'; + + @override + String get encoding => 'Kodowanie'; + + @override + String get algorithm => 'Algorytm'; + + @override + String get digits => 'Ilość cyfr'; + + @override + String get type => 'Typ'; + + @override + String get period => 'Cykl'; + + @override + String get rename => 'Zmień nazwę'; + + @override + String get cancel => 'Anuluj'; + + @override + String get delete => 'Usuń'; + + @override + String get dismiss => 'Odrzuć'; + + @override + String get addToken => 'Dodaj token'; + + @override + String get scanQrCode => 'Zeskanuj kod QR'; + + @override + String get enterDetailsForToken => 'Wprowadź szczegóły dla tokenu'; + + @override + String get pleaseEnterANameForThisToken => 'Wprowadź nazwę dla tokenu'; + + @override + String get pleaseEnterASecretForThisToken => 'Wprowadź sekret dla tokenu'; + + @override + String get theSecretDoesNotFitTheCurrentEncoding => 'Sekret nie odpowiada wybranemu sposobowi kodowania.'; + + @override + String get renameToken => 'Zmień nazwę tokenu'; + + @override + String get confirmDeletion => 'Potwierdź usunięcie'; + + @override + String confirmDeletionOf(Object name) { + return 'Jesteś pewien, że chcesz usunąć token: $name?'; + } + + @override + String get generatingPhonePart => 'Generowanie sekretu po stronie telefonu...'; + + @override + String get phonePart => 'Sekret po stronie telefonu:'; + + @override + String otpValueCopiedMessage(Object otpValue) { + return 'Jednorazowe hasło \"$otpValue\" skopiowane do schowka.'; + } + + @override + String get settings => 'Ustawienia'; + + @override + String get pushToken => 'Push token'; + + @override + String get theme => 'Motyw'; + + @override + String get lightTheme => 'Jasny'; + + @override + String get darkTheme => 'Ciemny'; + + @override + String get systemTheme => 'Motyw systemu'; + + @override + String get enablePolling => 'Włącz autentykację przez wiadomość push.'; + + @override + String get requestPushChallengesPeriodically => 'Wysyłaj zapytanie o push challenge cyklicznie. Włącz, jeśli push nie przychodzi normalnie.'; + + @override + String get synchronizePushTokens => 'Synchronizuj tokeny push.'; + + @override + String get synchronizesTokensWithServer => 'Synchronizuje tokeny push z serwerem privacyIDEA.'; + + @override + String get sync => 'Synchronizuj'; + + @override + String get synchronizingTokens => 'Synchronizacja tokenów.'; + + @override + String get allTokensSynchronized => 'Wszystkie tokeny są zsynchronizowane.'; + + @override + String get synchronizationFailed => 'Synchronizacja dla poniższych tokenów się nie udała, spróbuj ponownie:'; + + @override + String get tokensDoNotSupportSynchronization => 'Następujące tokeny nie wspierają synchronizacji i muszą zostać wdrożone od nowa:'; + + @override + String errorRollOutFailed(Object name, Object errorCode) { + return 'Wdrażanie tokenu $name nieudane. Kod błędu: $errorCode'; + } + + @override + String get errorSynchronizationNoNetworkConnection => 'Synchronizacja tokenów push nieudana, ponieważ serwer privacyIDEA jest nieosiągalny.'; + + @override + String errorRollOutNoConnectionToServer(Object name) { + return 'Brak połączenia z serwerem'; + } + + @override + String errorRollOutUnknownError(Object e) { + return 'Napotkano nieznany błąd. Wdrożenie tokenu niemożliwe: $e'; + } + + @override + String get rollingOut => 'Wdrażanie'; + + @override + String get pollingChallenges => 'Sprawdzanie nowych wyzwań'; + + @override + String get unexpectedError => 'Wystąpił nieoczekiwany błąd.'; + + @override + String get pollingFailNoNetworkConnection => 'Serwer jest nieosiągalny.'; + + @override + String get useDeviceLocaleTitle => 'Użyj języka urządzenia.'; + + @override + String get useDeviceLocaleDescription => 'Użyj języka urządzenia, jeśli jest wspierany. W innym wypadku zostanie ustawiony domyślny język angielski.'; + + @override + String get language => 'Język'; + + @override + String get authenticateToShowOtp => 'Zweryfikuj tożsamość, by pokazać hasło jednorazowe.'; + + @override + String get authenticateToUnLockToken => 'Zweryfikuj tożsamość, aby odblokować / zablokować token.'; + + @override + String get biometricRequiredTitle => 'Uwierzytelnianie biometryczne nie jest skonfigurowane.'; + + @override + String get biometricHint => 'Wymagana autentykacja'; + + @override + String get biometricNotRecognized => 'Nie rozpoznano. Spróbuj ponownie.'; + + @override + String get biometricSuccess => 'Autentykacja zakończona sukcesem!'; + + @override + String get deviceCredentialsRequiredTitle => 'Ustawienia zabezpieczeń urządzenia nie zostały skonfigurowane.'; + + @override + String get deviceCredentialsSetupDescription => 'Skonfiguruj ustawienia zabezpieczeń w ustawieniach urządzenia.'; + + @override + String get signInTitle => 'Wymagana autentykacja'; + + @override + String get goToSettingsButton => 'Idź do ustawień'; + + @override + String get goToSettingsDescription => 'Ustawienia zabezpieczeń, bądź uwierzytelnianie biometryczne nie są skonfigurowane w twoim urządzeniu. Skonfiguruj je w ustawieniach urządzenia.'; + + @override + String get lockOut => 'Uwierzytelnianie biometryczne jest wyłączone. Zablokuj i odblokuj ponownie ekran, żeby je włączyć.'; + + @override + String get authNotSupportedTitle => 'Skonfigurowane ustawienia zabezpieczeń albo uwierzytelnianie biometryczne jest wymagane.'; + + @override + String get authNotSupportedBody => 'To działanie wymaga skonfigurowania ustawień zabezpieczeń albo uwierzytelniania biometrycznego.'; + + @override + String get lock => 'Zablokuj'; + + @override + String get unlock => 'Odblokuj'; + + @override + String get noResultTitle => 'Nie zainstalowano jeszcze żadnego tokenu.'; + + @override + String get noResultText1 => 'Dotknij '; + + @override + String get noResultText2 => ' przycisku, żeby zacząć!'; + + @override + String onBoardingTitle1(Object appName) { + return '$appName'; + } + + @override + String get onBoardingText1 => 'Uwierzytelnianie dwuskładnikowe\nuczynione prostym'; + + @override + String get onBoardingTitle2 => 'Maksymalne Bezpieczeństwo'; + + @override + String get onBoardingText2 => 'Przechowuj tokeny w swoim urządzeniu\nzabezpieczone biometrycznie'; + + @override + String get onBoardingTitle3 => 'Odwiedź nas na Github'; + + @override + String get onBoardingText3 => 'Ta aplikacja jest w open source'; + + @override + String get errorLogTitle => 'Error logs'; + + @override + String get sendErrorHint => 'Wyślij nam dziennik błędów pocztą e-mail'; + + @override + String get enableVerboseLogging => 'Włącz szczegółowe rejestrowanie'; + + @override + String get clearErrorLogHint => 'Czyści lokalny plik dziennika błędów'; + + @override + String get logMenu => 'LogMenu'; + + @override + String get sendErrorDialogHeader => 'Wyślij przez e-mail'; + + @override + String get ok => 'Ok'; + + @override + String get noLogToSend => 'There is log to send.'; + + @override + String get errorMailBody => 'The error log file is attached.\nYou can replace this text with additional information about the error.'; + + @override + String get errorLogCleared => 'Wyczyszczono dzienniki błędów'; + + @override + String get showDetails => 'Pokaż szczegóły'; + + @override + String get open => 'Otwórz'; + + @override + String get sendErrorDialogBody => 'Napotkano nieoczekiwany błąd w aplikacji. Poniższa wiadomość może zostać wysłana do deweloperów poprzez email, żeby pomóc uniknąć tego problemu w przyszłości.'; + + @override + String get noFbToken => 'Brak dostępnego tokena Firebase'; + + @override + String get firebaseToken => 'Token Firebase'; + + @override + String get noPublicKey => 'Brak dostępnego klucza publicznego'; + + @override + String get publicKey => 'Klucz publiczny'; + + @override + String get editToken => 'Edytuj token'; + + @override + String get edit => 'Edytuj'; + + @override + String get save => 'Zapisz'; + + @override + String get validFor => 'Ważny przez'; + + @override + String get validUntil => 'Ważny do'; + + @override + String get deleteLockedToken => 'Uwierzytelnij, aby usunąć zablokowany token.'; + + @override + String get editLockedToken => 'Aby edytować zablokowany token, należy się uwierzytelnić.'; + + @override + String get uncollapseLockedFolder => 'Uwierzytelnij, aby otworzyć zablokowany folder.'; + + @override + String get renameTokenFolder => 'Zmiana nazwy folderu'; + + @override + String get addANewFolder => 'Utwórz nowy folder'; + + @override + String get folderName => 'Nazwa folderu'; + + @override + String get retryRollout => 'Ponowne uruchomienie'; + + @override + String get generatingRSAKeyPair => 'Generowanie pary kluczy RSA'; + + @override + String get generatingRSAKeyPairFailed => 'Generowanie pary kluczy RSA nieudane'; + + @override + String get sendingRSAPublicKey => 'Wysyłanie publicznego klucza RSA'; + + @override + String get sendingRSAPublicKeyFailed => 'Wysyłanie publicznego klucza RSA nieudane'; + + @override + String get parsingResponse => 'Analizowanie odpowiedzi'; + + @override + String get parsingResponseFailed => 'Analizowanie odpowiedzi nieudane'; + + @override + String get rolloutCompleted => 'Wdrożenie zakończone'; + + @override + String get authToAcceptPushRequest => 'Uwierzytelnij, aby zaakceptować żądanie push.'; + + @override + String get authToDeclinePushRequest => 'Uwierzytelnij, aby odrzucić żądanie push.'; + + @override + String get incomingAuthRequestError => 'Wiadomość nie zawierała wymaganych danych lub dane były zniekształcone.'; + + @override + String get imageUrl => 'Adres URL obrazu'; + + @override + String get errorRollOutSSLHandshakeFailed => 'Uścisk dłoni SSL nie powiódł się. Rozwijanie nie jest możliwe.'; + + @override + String errorWhenPullingChallenges(Object name) { + return 'Wystąpił błąd podczas odpytywania o wyzwania $name'; + } + + @override + String errorRollOutTokenExpired(Object name) { + return 'Wstać z łóżka tego tokena nie jest już możliwe.\nToken $name wygasł.'; + } + + @override + String get yes => 'Tak'; + + @override + String get no => 'Nie'; + + @override + String get butDiscardIt => 'ale odrzucić go'; + + @override + String get declineIt => 'odrzuć go'; + + @override + String get requestTriggerdByUserQuestion => 'Czy ta prośba została wywołana przez Ciebie?'; + + @override + String get grantCameraPermissionDialogTitle => 'Uprawnienie do kamery nie zostało przyznane'; + + @override + String get grantCameraPermissionDialogContent => 'Przyznaj uprawnienia kamery do skanowania kodów QR.'; + + @override + String get grantCameraPermissionDialogPermanentlyDenied => 'Uprawnienia do aparatu zostały trwale zablokowane. Przyznaj uprawnienia aparatu w ustawieniach telefonu.'; + + @override + String get grantCameraPermissionDialogButton => 'Grant permission'; + + @override + String get decryptErrorTitle => 'Decryption error'; + + @override + String get decryptErrorContent => 'Niestety, aplikacja nie była w stanie odszyfrować tokenów. Oznacza to, że klucz szyfrowania jest uszkodzony. Możesz spróbować ponownie lub usunąć dane aplikacji, co spowoduje usunięcie tokenów w aplikacji.'; + + @override + String get decryptErrorButtonDelete => 'Usuń'; + + @override + String get decryptErrorButtonSendError => 'Wyślij błąd'; + + @override + String get decryptErrorButtonRetry => 'Ponów próbę'; + + @override + String get decryptErrorDeleteConfirmationContent => 'Czy na pewno chcesz usunąć dane aplikacji?'; + + @override + String get hidePushTokens => 'Ukryj tokeny push'; + + @override + String get hidePushTokensDescription => 'Ukryj tokeny push z listy tokenów. Nie spowoduje to usunięcia tokenów i będą one nadal widoczne na osobnym ekranie'; + + @override + String get licensesAndVersion => 'Licencje i wersja'; +} diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 589acc423..708729a1d 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -2,140 +2,95 @@ "@@last_modified": "2023-08-07", "guide": "Handleiding", "@guide": { - "description": "Button to open the guide screen.", - "type": "text", - "placeholders": {} + "description": "Button to open the guide screen." }, "accept": "Accepteren", "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets accepted by the user." }, "decline": "Weigeren", "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} + "description": "Label for e.g. a button. Something gets declined by the user." }, "name": "Naam", "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens name should be entered." }, "secret": "Geheim", "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} + "description": "Describes the field where the tokens secret should be entered." }, "encoding": "Codering", "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "algorithm": "Algoritme", "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the encoding is selected." }, "digits": "Cijfers", "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the number of digits for the opt value is selected." }, "type": "Type", "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the type of the token is selected." }, "period": "Duur", "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} + "description": "Title of the dropdown button where the period of the totp token is selected." }, "rename": "Wijzigen", "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes renaming the token." }, "cancel": "Annuleren", "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} + "description": "Button to cancel an action." }, "delete": "Verwijderen", "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} + "description": "Label that describes deleting the token." }, "dismiss": "Sluiten", "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} + "description": "Text of a button that closes a dialog." }, "addToken": "Token toevoegen", "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} + "description": "The button to open the screen to add tokens by hand." }, "scanQrCode": "Scan QR-Code", "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} + "description": "The button to scan otpauth qr-codes." }, "enterDetailsForToken": "Voer informatie over token in", "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." }, "pleaseEnterANameForThisToken": "Voer de naam in voor deze token.", "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a name for a token." }, "pleaseEnterASecretForThisToken": "Voer de geheime sleutel in voor deze token.", "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user to enter a secret for a token." }, "theSecretDoesNotFitTheCurrentEncoding": "De geheime sleutel past niet bij de huidige codering", "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} + "description": "Hint telling the user that the secret does not fit the selected encoding." }, "renameToken": "Hernoem token", "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a new name for a token can be entered." }, "confirmDeletion": "Bevestig verwijderen", "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} + "description": "Title of the dialog where a token can be deleted." }, "confirmDeletionOf": "Weet u zeker dat u {name} wilt verwijderen?", "@confirmDeletionOf": { "description": "Asks for confirmation on deleting a token.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234" @@ -144,20 +99,15 @@ }, "generatingPhonePart": "Genereren telefoon gedeelte", "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone part gets generated right now." }, "phonePart": "Telefoon gedeelte:", "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." }, "otpValueCopiedMessage": "Wachtwoord \"{otpValue}\" gekopieerd naar het klembord.", "@otpValueCopiedMessage": { "description": "Tells the user that the otp value was copied to the clipboard.", - "type": "text", "placeholders": { "otpValue": { "example": "055374" @@ -166,98 +116,67 @@ }, "settings": "Instellingen", "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} + "description": "Button to open the settings page." }, "pushToken": "Push Token", "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} + "description": "Title for the settings block concerning the push tokens." }, "theme": "Thema", "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} + "description": "Title of the setting group where the theme can be selected." }, "lightTheme": "Licht", "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} + "description": "The light theme." }, "darkTheme": "Donker", "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} + "description": "The dark theme." }, "systemTheme": "Gebruik thema van het apparaat", "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} + "description": "The systems theme." }, "enablePolling": "Zoeken aanzetten", "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} + "description": "Name of the setting switch that enables polling." }, "requestPushChallengesPeriodically": "Activeer het zoeken naar berichten. Gebruik deze optie wanneer de push berichten niet worden ontvangen.", "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} + "description": "The description of the polling feature." }, "synchronizePushTokens": "Synchroniseer push tokens", "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Title of synchronizing push tokens in settings." }, "synchronizesTokensWithServer": "Synchroniseert tokens met de privacyIDEA server.", "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} + "description": "Description of synchronizing push tokens in settings." }, "sync": "Synchroniseer", "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} + "description": "Text of button that is used to synchronize push tokens." }, "synchronizingTokens": "Tokens synchroniseren.", "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} + "description": "Title of the push synchronization dialog." }, "allTokensSynchronized": "Alle tokens zijn gesynchroniseerd.", "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." }, "synchronizationFailed": "Synchroniseren mislukt voor de volgende tokens, probeer het opnieuw:", "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} + "description": "Headline for the list of tokens where the synchronization failed." }, "tokensDoNotSupportSynchronization": "Voor de volgende tokens wordt synchroniseren niet ondersteunt, ze moeten opnieuw worden aangeleverd:", "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." }, "errorRollOutFailed": "Uitrollen van token {name} mislukt. Fout code: {errorCode}", "@errorRollOutFailed": { "description": "Tells the user that the token could not be rolled out, because a network error occurred.", - "type": "text", "placeholders": { "name": { "example": "PUSH1234A" @@ -269,9 +188,7 @@ }, "errorSynchronizationNoNetworkConnection": "Token synchroniseren mislukt, privacyIDEA server kan niet worden bereikt.", "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." }, "errorRollOutUnknownError": "Een onbekende fout heeft plaats gevonden. Uitrollen is niet mogelijk: {e}", "@errorRollOutUnknownError": { @@ -285,427 +202,233 @@ }, "rollingOut": "Uitrollen", "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} + "description": "Label that tells the user that the token is being rolled out." }, "pollingChallenges": "Zoeken naar nieuwe aanvragen", "@pollingChallenges": { - "type": "text", - "placeholders": {} + "type": "text" }, "unexpectedError": "Er is een onverwachte fout opgetreden.", "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} + "description": "Title of page report mode." }, "pollingFailNoNetworkConnection": "Zoeken naar berichten mislukt. Server kan niet worden bereikt.", "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} + "description": "Tells the user that the roll-out failed because no network connection is available." }, "useDeviceLocaleTitle": "Gebruik de taal van het apparaat", "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Title of the switch tile where using the devices language can be enabled." }, "useDeviceLocaleDescription": "Gebruik de taal van het apparaat wanneer het wordt ondersteund, val anders terug op Engels.", "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} + "description": "Description of the switch tile where using the devices language can be enabled." }, "language": "Taal", "@language": { - "description": "Title of language setting group.", - "type": "text", - "placeholders": {} + "description": "Title of language setting group." }, "authenticateToShowOtp": "Authenticeer om het eenmalige wachtwoord te tonen.", "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to view a one time password." }, "authenticateToUnLockToken": "Authenticeer om de vergrendeling van de token te wijzigen.", "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} + "description": "Reason to authenticate when trying to lock or unlock a token." }, "biometricRequiredTitle": "Biometrie is niet ingesteld", "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." }, "biometricHint": "Authenticatie vereist", "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." }, "biometricNotRecognized": "Niet herkend. Probeer opnieuw.", "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." }, "biometricSuccess": "Authenticatie geslaagd", "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsRequiredTitle": "Inloggevens van het apparaat zijn niet ingesteld", "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." }, "deviceCredentialsSetupDescription": "Stel de inloggegevens in, bij de instellingen van het apparaat", "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." }, "signInTitle": "Authenticatie vereist", "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." }, "goToSettingsButton": "Ga naar instellingen", "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." }, "goToSettingsDescription": "Authenticatie via inloggegevens of biometrie is niet ingesteld. Stel het in bij de instellingen van het apparaat.", "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." }, "lockOut": "Biometrische authenticatie staat uit. Vergrendel en ontgrendel het scherm om het aan te zetten.", "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, "authNotSupportedTitle": "Apparaat inloggevens of biometrie is vereist", "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." }, "authNotSupportedBody": "Deze actie vereist dat het apparaat is beveiligd met inlogggevens of biometrie.", "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." }, "lock": "Vergrendel", "@lock": { - "description": "Description of button that locks a token.", - "type": "text" + "description": "Description of button that locks a token." }, "unlock": "Ontgrendel", "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" + "description": "Description of button that unlocks a token." }, "noResultTitle": "Nog geen token opgeslagen.", "@noResultTitle": { - "description": "No tokens installed yet.", - "type": "text" + "description": "No tokens installed yet." }, "noResultText1": "Tik op ", "@noResultText1": { - "description": "first noresult text", - "type": "text" + "description": "first noresult text" }, "noResultText2": " de knop om te beginnen!", "@noResultText2": { - "description": "second noresult text", - "type": "text" + "description": "second noresult text" }, "onBoardingTitle1": "{appName}", "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", "placeholders": { "appName": { - "type": "String", "example": "privacyIDEA Authenticator" } } }, "onBoardingText1": "Twee-factoren authenticatie\nmakkelijk gemaakt", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, "onBoardingTitle2": "Maximale Beveiliging", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, "onBoardingText2": "Bewaar tokens op uw apparaat\nbeveiligd door uw biometrische gegevens", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, "onBoardingTitle3": "Bezoek ons op Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, "onBoardingText3": "Deze app is open source", - "@onBoardingText3": { - "description": "onBoardingText3", - "type": "text" - }, "errorLogTitle": "Foutlogs", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, "sendErrorHint": "Stuur ons de error log via e-mail", "@sendErrorHint": { - "description": "Hint for the user about what he will send.", - "type": "text" + "description": "Hint for the user about what he will send." }, "enableVerboseLogging": "Uitgebreide logboekregistratie inschakelen", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, "clearErrorLogHint": "Wist het lokale foutenlogbestand", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, "logMenu": "Logmenu", - "@logMenu": { - "description": "logMenu", - "type": "text" - }, "sendErrorDialogHeader": "Verzenden via e-mail", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, "noLogToSend": "Er is een log om te verzenden.", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "Het foutlogbestand is bijgevoegd.", - "@errorLogFileAttached": { - "description": "Message for email body", - "type": "text" + "errorMailBody": "Het foutlogbestand is bijgevoegd.\nU kunt deze tekst vervangen door aanvullende informatie over de fout.", + "@errorMailBody": { + "description": "Message for email body" }, "errorLogCleared": "Foutlogs gewist", - "@errorLogCleared": { - "description": "errorLogCleared", - "type": "text" - }, "showDetails": "Details tonen", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, "open": "Openen", - "@open": { - "description": "open", - "type": "text" - }, "sendErrorDialogBody": "Een onverwachte fout heeft plaatsgevonden in de applicatie. De onderstaande informatie kan worden verstuurd naar de ontwikkelaars via e-mail om het probleem in de toekomst te voorkomen.", "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" + "description": "Description shown to the user about what info the error report contains." }, "noFbToken": "Geen Firebase Token beschikbaar", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, "firebaseToken": "Firebase Token", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, "noPublicKey": "Geen openbare sleutel beschikbaar", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, "publicKey": "Openbare sleutel", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, "editToken": "Token bewerken", - "@editToken": { - "description": "editToken", - "type": "text" - }, "edit": "Bewerken", - "@edit": { - "description": "edit", - "type": "text" - }, "save": "Opslaan", - "@save": { - "description": "save", - "type": "text" - }, "validFor": "Geldig voor", - "@validFor": { - "description": "validFor", - "type": "text" - }, "validUntil": "Geldig tot", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, "deleteLockedToken": "Verifieer om het vergrendelde token te verwijderen.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, "editLockedToken": "Verifieer om het vergrendelde token te bewerken.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, "uncollapseLockedFolder": "Verifieer om de vergrendelde map te openen.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, "renameTokenFolder": "Map hernoemen", - "@renameTokenFolder": { - "description": "renameTokenFolder", - "type": "text" - }, "addANewFolder": "Nieuwe map maken", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, "folderName": "Mapnaam", - "@folderName": { - "description": "folderName", - "type": "text" - }, "retryRollout": "Opnieuw uitrollen", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, "generatingRSAKeyPair": "Genereren RSA sleutelpaar", "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "generatingRSAKeyPairFailed": "Genereren RSA sleutelpaar mislukt", "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKey": "Versturen van de openbare RSA sleutel", "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "sendingRSAPublicKeyFailed": "Versturen van de openbare RSA sleutel mislukt", "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponse": "Antwoord analyseren", "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "parsingResponseFailed": "Antwoord analyseren mislukt", "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "rolloutCompleted": "Uitrollen voltooid", "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "errorRollOutNoConnectionToServer": "Uitrollen mislukt. Geen verbinding met de server.", "@errorRollOutNoConnectionToServer": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "authToAcceptPushRequest": "Authenticeer om de push aanvraag te accepteren.", "@authToAcceptPushRequest": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "authToDeclinePushRequest": "Authenticeer om de push aanvraag te weigeren.", "@authToDeclinePushRequest": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "incomingAuthRequestError": "Het bericht bevatte niet de benodigde gegevens of de gegevens waren misvormd.", "@incomingAuthRequestError": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "imageUrl": "Afbeeldings-URL", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, "errorRollOutSSLHandshakeFailed": "SSL-handdruk mislukt. Uitrollen niet mogelijk.", "@errorRollOutSSLHandshakeFailed": { - "description": "Message for the rollout process", - "type": "text" + "description": "Message for the rollout process" }, "errorWhenPullingChallenges": "Er is een fout opgetreden bij het zoeken naar uitdagingen van {name}", "@errorWhenPullingChallenges": { - "description": "errorWhenPullingChallenges", - "type": "text", "placeholders": { "name": { - "type": "String", "example": "PUSH1234A" } } }, "errorRollOutTokenExpired": "Het uitrollen van dit token is niet meer mogelijk.\nHet token {name} is verlopen.", - "@errorRollOutTokenExpired": { - "description": "errorRollOutTokenExpired", - "type": "text" - }, "yes": "Ja", - "@yes": { - "description": "yes", - "type": "text" - }, "no": "Nee", - "@no": { - "description": "no", - "type": "text" - }, "butDiscardIt": "maar verwijder", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, "declineIt": "weigeren", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, "requestTriggerdByUserQuestion": "Is dit verzoek door jou gedaan?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } + "grantCameraPermissionDialogTitle": "Cameratoestemming is niet verleend", + "grantCameraPermissionDialogContent": "Geef de camera toestemming om QR-codes te scannen.", + "grantCameraPermissionDialogPermanentlyDenied": "Cameratoestemming is permanent geweigerd. Geef de camera toestemming in de instellingen van uw telefoon.", + "grantCameraPermissionDialogButton": "Toestemming verlenen", + "decryptErrorTitle": "Fout bij decoderen", + "decryptErrorContent": "Helaas heeft de app je tokens niet kunnen decoderen. Dit geeft aan dat de coderingssleutel is verbroken. U kunt het opnieuw proberen of de app-gegevens verwijderen, waardoor de tokens in de app worden verwijderd.", + "decryptErrorButtonDelete": "Verwijderen", + "decryptErrorButtonSendError": "Fout verzenden", + "decryptErrorButtonRetry": "Opnieuw proberen", + "decryptErrorDeleteConfirmationContent": "Weet je zeker dat je de app-gegevens wilt verwijderen?", + "hidePushTokens": "Verberg push tokens", + "hidePushTokensDescription": "Verberg push tokens uit de token lijst. Hierdoor worden de tokens niet verwijderd en blijven ze zichtbaar op een apart scherm.", + "licensesAndVersion": "Licenties en versie" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 5765d27ca..d8979d3ff 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,716 +1,432 @@ -{ - "@@last_modified": "2023-08-07", - "guide": "Przewodnik", - "@guide": { - "description": "Button to open the guide screen.", - "type": "text", - "placeholders": {} - }, - "accept": "Zatwierdź", - "@accept": { - "description": "Label for e.g. a button. Something gets accepted by the user.", - "type": "text", - "placeholders": {} - }, - "decline": "Odrzuć", - "@decline": { - "description": "Label for e.g. a button. Something gets declined by the user.", - "type": "text", - "placeholders": {} - }, - "name": "Nazwa", - "@name": { - "description": "Describes the field where the tokens name should be entered.", - "type": "text", - "placeholders": {} - }, - "secret": "Sekret", - "@secret": { - "description": "Describes the field where the tokens secret should be entered.", - "type": "text", - "placeholders": {} - }, - "encoding": "Kodowanie", - "@encoding": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} - }, - "algorithm": "Algorytm", - "@algorithm": { - "description": "Title of the dropdown button where the encoding is selected.", - "type": "text", - "placeholders": {} - }, - "digits": "Ilość cyfr", - "@digits": { - "description": "Title of the dropdown button where the number of digits for the opt value is selected.", - "type": "text", - "placeholders": {} - }, - "type": "Typ", - "@type": { - "description": "Title of the dropdown button where the type of the token is selected.", - "type": "text", - "placeholders": {} - }, - "period": "Cykl", - "@period": { - "description": "Title of the dropdown button where the period of the totp token is selected.", - "type": "text", - "placeholders": {} - }, - "rename": "Zmień nazwę", - "@rename": { - "description": "Label that describes renaming the token.", - "type": "text", - "placeholders": {} - }, - "cancel": "Anuluj", - "@cancel": { - "description": "Button to cancel an action.", - "type": "text", - "placeholders": {} - }, - "delete": "Usuń", - "@delete": { - "description": "Label that describes deleting the token.", - "type": "text", - "placeholders": {} - }, - "dismiss": "Odrzuć", - "@dismiss": { - "description": "Text of a button that closes a dialog.", - "type": "text", - "placeholders": {} - }, - "addToken": "Dodaj token", - "@addToken": { - "description": "The button to open the screen to add tokens by hand.", - "type": "text", - "placeholders": {} - }, - "scanQrCode": "Zeskanuj kod QR", - "@scanQrCode": { - "description": "The button to scan otpauth qr-codes.", - "type": "text", - "placeholders": {} - }, - "enterDetailsForToken": "Wprowadź szczegóły dla tokenu", - "@enterDetailsForToken": { - "description": "Title of the screen where tokens are created manually, tells the user to enter all required values.", - "type": "text", - "placeholders": {} - }, - "pleaseEnterANameForThisToken": "Wprowadź nazwę dla tokenu", - "@pleaseEnterANameForThisToken": { - "description": "Hint telling the user to enter a name for a token.", - "type": "text", - "placeholders": {} - }, - "pleaseEnterASecretForThisToken": "Wprowadź sekret dla tokenu", - "@pleaseEnterASecretForThisToken": { - "description": "Hint telling the user to enter a secret for a token.", - "type": "text", - "placeholders": {} - }, - "theSecretDoesNotFitTheCurrentEncoding": "Sekret nie odpowiada wybranemu sposobowi kodowania.", - "@theSecretDoesNotFitTheCurrentEncoding": { - "description": "Hint telling the user that the secret does not fit the selected encoding.", - "type": "text", - "placeholders": {} - }, - "renameToken": "Zmień nazwę tokenu", - "@renameToken": { - "description": "Title of the dialog where a new name for a token can be entered.", - "type": "text", - "placeholders": {} - }, - "confirmDeletion": "Potwierdź usunięcie", - "@confirmDeletion": { - "description": "Title of the dialog where a token can be deleted.", - "type": "text", - "placeholders": {} - }, - "confirmDeletionOf": "Jesteś pewien, że chcesz usunąć token: {name}?", - "@confirmDeletionOf": { - "description": "Asks for confirmation on deleting a token.", - "type": "text", - "placeholders": { - "name": { - "example": "PUSH1234" - } - } - }, - "generatingPhonePart": "Generowanie sekretu po stronie telefonu...", - "@generatingPhonePart": { - "description": "Title of a dialog telling the user that the phone part gets generated right now.", - "type": "text", - "placeholders": {} - }, - "phonePart": "Sekret po stronie telefonu:", - "@phonePart": { - "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user.", - "type": "text", - "placeholders": {} - }, - "otpValueCopiedMessage": "Jednorazowe hasło \"{otpValue}\" skopiowane do schowka.", - "@otpValueCopiedMessage": { - "description": "Tells the user that the otp value was copied to the clipboard.", - "type": "text", - "placeholders": { - "otpValue": { - "example": "055374" - } - } - }, - "settings": "Ustawienia", - "@settings": { - "description": "Button to open the settings page.", - "type": "text", - "placeholders": {} - }, - "pushToken": "Push token", - "@pushToken": { - "description": "Title for the settings block concerning the push tokens.", - "type": "text", - "placeholders": {} - }, - "theme": "Motyw", - "@theme": { - "description": "Title of the setting group where the theme can be selected.", - "type": "text", - "placeholders": {} - }, - "lightTheme": "Jasny", - "@lightTheme": { - "description": "The light theme.", - "type": "text", - "placeholders": {} - }, - "darkTheme": "Ciemny", - "@darkTheme": { - "description": "The dark theme.", - "type": "text", - "placeholders": {} - }, - "systemTheme": "Motyw systemu", - "@systemTheme": { - "description": "The systems theme.", - "type": "text", - "placeholders": {} - }, - "enablePolling": "Włącz autentykację przez wiadomość push.", - "@enablePolling": { - "description": "Name of the setting switch that enables polling.", - "type": "text", - "placeholders": {} - }, - "requestPushChallengesPeriodically": "Wysyłaj zapytanie o push challenge cyklicznie. Włącz, jeśli push nie przychodzi normalnie.", - "@requestPushChallengesPeriodically": { - "description": "The description of the polling feature.", - "type": "text", - "placeholders": {} - }, - "synchronizePushTokens": "Synchronizuj tokeny push.", - "@synchronizePushTokens": { - "description": "Title of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} - }, - "synchronizesTokensWithServer": "Synchronizuje tokeny push z serwerem privacyIDEA.", - "@synchronizesTokensWithServer": { - "description": "Description of synchronizing push tokens in settings.", - "type": "text", - "placeholders": {} - }, - "sync": "Synchronizuj", - "@sync": { - "description": "Text of button that is used to synchronize push tokens.", - "type": "text", - "placeholders": {} - }, - "synchronizingTokens": "Synchronizacja tokenów.", - "@synchronizingTokens": { - "description": "Title of the push synchronization dialog.", - "type": "text", - "placeholders": {} - }, - "allTokensSynchronized": "Wszystkie tokeny są zsynchronizowane.", - "@allTokensSynchronized": { - "description": "Content of the push synchronization dialog. Signaling the user that everything worked.", - "type": "text", - "placeholders": {} - }, - "synchronizationFailed": "Synchronizacja dla poniższych tokenów się nie udała, spróbuj ponownie:", - "@synchronizationFailed": { - "description": "Headline for the list of tokens where the synchronization failed.", - "type": "text", - "placeholders": {} - }, - "tokensDoNotSupportSynchronization": "Następujące tokeny nie wspierają synchronizacji i muszą zostać wdrożone od nowa:", - "@tokensDoNotSupportSynchronization": { - "description": "Informs the user that the following tokens cannot be synchronized as they do not support that.", - "type": "text", - "placeholders": {} - }, - "errorRollOutFailed": "Wdrażanie tokenu {name} nieudane. Kod błędu: {errorCode}", - "@errorRollOutFailed": { - "description": "Tells the user that the token could not be rolled out, because a network error occurred.", - "type": "text", - "placeholders": { - "name": { - "example": "PUSH1234A" - }, - "errorCode": { - "example": "500" - } - } - }, - "errorSynchronizationNoNetworkConnection": "Synchronizacja tokenów push nieudana, ponieważ serwer privacyIDEA jest nieosiągalny.", - "@errorSynchronizationNoNetworkConnection": { - "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached.", - "type": "text", - "placeholders": {} - }, - "errorRollOutUnknownError": "Napotkano nieznany błąd. Wdrożenie tokenu niemożliwe: {e}", - "@errorRollOutUnknownError": { - "description": "Tells the user that the roll-out failed because of an unknown error.", - "type": "text", - "placeholders": { - "e": { - "example": "IllegalArgumentException on Line 5 ..." - } - } - }, - "rollingOut": "Wdrażanie", - "@rollingOut": { - "description": "Label that tells the user that the token is being rolled out.", - "type": "text", - "placeholders": {} - }, - "pollingChallenges": "Sprawdzanie nowych wyzwań", - "@pollingChallenges": { - "type": "text", - "placeholders": {} - }, - "unexpectedError": "Wystąpił nieoczekiwany błąd.", - "@unexpectedError": { - "description": "Title of page report mode.", - "type": "text", - "placeholders": {} - }, - "pollingFailNoNetworkConnection": "Serwer jest nieosiągalny.", - "@pollingFailNoNetworkConnection": { - "description": "Tells the user that the roll-out failed because no network connection is available.", - "type": "text", - "placeholders": {} - }, - "useDeviceLocaleTitle": "Użyj języka urządzenia.", - "@useDeviceLocaleTitle": { - "description": "Title of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} - }, - "useDeviceLocaleDescription": "Użyj języka urządzenia, jeśli jest wspierany. W innym wypadku zostanie ustawiony domyślny język angielski.", - "@useDeviceLocaleDescription": { - "description": "Description of the switch tile where using the devices language can be enabled.", - "type": "text", - "placeholders": {} - }, - "language": "Język", - "@language": { - "description": "Title of language setting group.", - "type": "text", - "placeholders": {} - }, - "authenticateToShowOtp": "Zweryfikuj tożsamość, by pokazać hasło jednorazowe.", - "@authenticateToShowOtp": { - "description": "Reason to authenticate when trying to view a one time password.", - "type": "text", - "placeholders": {} - }, - "authenticateToUnLockToken": "Zweryfikuj tożsamość, aby odblokować / zablokować token.", - "@authenticateToUnLockToken": { - "description": "Reason to authenticate when trying to lock or unlock a token.", - "type": "text", - "placeholders": {} - }, - "biometricRequiredTitle": "Uwierzytelnianie biometryczne nie jest skonfigurowane.", - "@biometricRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" - }, - "biometricHint": "Wymagana autentykacja", - "@biometricHint": { - "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters.", - "type": "text" - }, - "biometricNotRecognized": "Nie rozpoznano. Spróbuj ponownie.", - "@biometricNotRecognized": { - "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters.", - "type": "text" - }, - "biometricSuccess": "Autentykacja zakończona sukcesem!", - "@biometricSuccess": { - "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters.", - "type": "text" - }, - "deviceCredentialsRequiredTitle": "Ustawienia zabezpieczeń urządzenia nie zostały skonfigurowane.", - "@deviceCredentialsRequiredTitle": { - "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters.", - "type": "text" - }, - "deviceCredentialsSetupDescription": "Skonfiguruj ustawienia zabezpieczeń w ustawieniach urządzenia.", - "@deviceCredentialsSetupDescription": { - "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side.", - "type": "text" - }, - "signInTitle": "Wymagana autentykacja", - "@signInTitle": { - "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters.", - "type": "text" - }, - "goToSettingsButton": "Idź do ustawień", - "@goToSettingsButton": { - "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters.", - "type": "text" - }, - "goToSettingsDescription": "Ustawienia zabezpieczeń, bądź uwierzytelnianie biometryczne nie są skonfigurowane w twoim urządzeniu. Skonfiguruj je w ustawieniach urządzenia.", - "@goToSettingsDescription": { - "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device.", - "type": "text" - }, - "lockOut": "Uwierzytelnianie biometryczne jest wyłączone. Zablokuj i odblokuj ponownie ekran, żeby je włączyć.", - "@lockOut": { - "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side.", - "type": "text" - }, - "authNotSupportedTitle": "Skonfigurowane ustawienia zabezpieczeń albo uwierzytelnianie biometryczne jest wymagane.", - "@authNotSupportedTitle": { - "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" - }, - "authNotSupportedBody": "To działanie wymaga skonfigurowania ustawień zabezpieczeń albo uwierzytelniania biometrycznego.", - "@authNotSupportedBody": { - "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action.", - "type": "text" - }, - "lock": "Zablokuj", - "@lock": { - "description": "Description of button that locks a token.", - "type": "text" - }, - "unlock": "Odblokuj", - "@unlock": { - "description": "Description of button that unlocks a token.", - "type": "text" - }, - "noResultTitle": "Nie zainstalowano jeszcze żadnego tokenu.", - "@noResultTitle": { - "description": "No tokens installed yet.", - "type": "text" - }, - "noResultText1": "Dotknij ", - "@noResultText1": { - "description": "first noresult text", - "type": "text" - }, - "noResultText2": " przycisku, żeby zacząć!", - "@noResultText2": { - "description": "second noresult text", - "type": "text" - }, - "onBoardingTitle1": "{appName}", - "@onBoardingTitle1": { - "description": "onboardingTitle1", - "type": "text", - "placeholders": { - "appName": { - "type": "String", - "example": "privacyIDEA Authenticator" - } - } - }, - "onBoardingText1": "Uwierzytelnianie dwuskładnikowe\nuczynione prostym", - "@onBoardingText1": { - "description": "onboardingText1", - "type": "text" - }, - "onBoardingTitle2": "Maksymalne Bezpieczeństwo", - "@onBoardingTitle2": { - "description": "onBoardingTitle2", - "type": "text" - }, - "onBoardingText2": "Przechowuj tokeny w swoim urządzeniu\nzabezpieczone biometrycznie", - "@onBoardingText2": { - "description": "onboardingText2", - "type": "text" - }, - "onBoardingTitle3": "Odwiedź nas na Github", - "@onBoardingTitle3": { - "description": "onBoardingTitle3", - "type": "text" - }, - "onBoardingText3": "Ta aplikacja jest w open source", - "@onBoardingText3": { - "description": "onBoardingTitle3", - "type": "text" - }, - "errorLogTitle": "Error logs", - "@errorLogTitle": { - "description": "errorLogTitle", - "type": "text" - }, - "sendErrorHint": "Wyślij nam dziennik błędów pocztą e-mail", - "@sendErrorHint": { - "description": "Hint for the user about what he will send.", - "type": "text" - }, - "enableVerboseLogging": "Włącz szczegółowe rejestrowanie", - "@enableVerboseLogging": { - "description": "enableVerboseLogging", - "type": "text" - }, - "clearErrorLogHint": "Czyści lokalny plik dziennika błędów", - "@clearErrorLogHint": { - "description": "clearErrorLogHint", - "type": "text" - }, - "logMenu": "LogMenu", - "@logMenu": { - "description": "logMenu", - "type": "text" - }, - "sendErrorDialogHeader": "Wyślij przez e-mail", - "@sendErrorDialogHeader": { - "description": "sendErrorDialogHeader", - "type": "text" - }, - "ok": "Ok", - "@ok": { - "description": "ok", - "type": "text" - }, - "noLogToSend": "There is log to send.", - "@noLogToSend": { - "description": "noLogToSend", - "type": "text" - }, - "errorLogFileAttached": "Plik dziennika błędów jest dołączony", - "@errorLogFileAttached": { - "description": "Message for email body", - "type": "text" - }, - "errorLogCleared": "Wyczyszczono dzienniki błędów", - "@errorLogCleared": { - "description": "errorLogCleared", - "type": "text" - }, - "showDetails": "Pokaż szczegóły", - "@showDetails": { - "description": "showDetails", - "type": "text" - }, - "open": "Otwórz", - "@open": { - "description": "open", - "type": "text" - }, - "sendErrorDialogBody": "Napotkano nieoczekiwany błąd w aplikacji. Poniższa wiadomość może zostać wysłana do deweloperów poprzez email, żeby pomóc uniknąć tego problemu w przyszłości.", - "@sendErrorDialogBody": { - "description": "Description shown to the user about what info the error report contains.", - "type": "text" - }, - "noFbToken": "Brak dostępnego tokena Firebase", - "@noFbToken": { - "description": "noFbToken", - "type": "text" - }, - "firebaseToken": "Token Firebase", - "@firebaseToken": { - "description": "firebaseToken", - "type": "text" - }, - "noPublicKey": "Brak dostępnego klucza publicznego", - "@noPublicKey": { - "description": "noPublicKey", - "type": "text" - }, - "publicKey": "Klucz publiczny", - "@publicKey": { - "description": "publicKey", - "type": "text" - }, - "editToken": "Edytuj token", - "@editToken": { - "description": "editToken", - "type": "text" - }, - "edit": "Edytuj", - "@edit": { - "description": "edit", - "type": "text" - }, - "save": "Zapisz", - "@save": { - "description": "save", - "type": "text" - }, - "validFor": "Ważny przez", - "@validFor": { - "description": "validFor", - "type": "text" - }, - "validUntil": "Ważny do", - "@validUntil": { - "description": "validUntil", - "type": "text" - }, - "deleteLockedToken": "Uwierzytelnij, aby usunąć zablokowany token.", - "@deleteLockedToken": { - "description": "deleteLockedToken", - "type": "text" - }, - "editLockedToken": "Aby edytować zablokowany token, należy się uwierzytelnić.", - "@editLockedToken": { - "description": "editLockedToken", - "type": "text" - }, - "uncollapseLockedFolder": "Uwierzytelnij, aby otworzyć zablokowany folder.", - "@uncollapseLockedFolder": { - "description": "uncollapseLockedFolder", - "type": "text" - }, - "renameTokenFolder": "Zmiana nazwy folderu", - "@renameTokenFolder": { - "description": "renameTokenFolder", - "type": "text" - }, - "addANewFolder": "Utwórz nowy folder", - "@addANewFolder": { - "description": "addANewFolder", - "type": "text" - }, - "folderName": "Nazwa folderu", - "@folderName": { - "description": "folderName", - "type": "text" - }, - "retryRollout": "Ponowne uruchomienie", - "@retryRollout": { - "description": "retryRollout", - "type": "text" - }, - "generatingRSAKeyPair": "Generowanie pary kluczy RSA", - "@generatingRSAKeyPair": { - "description": "Message for the rollout process", - "type": "text" - }, - "generatingRSAKeyPairFailed": "Generowanie pary kluczy RSA nieudane", - "@generatingRSAKeyPairFailed": { - "description": "Message for the rollout process", - "type": "text" - }, - "sendingRSAPublicKey": "Wysyłanie publicznego klucza RSA", - "@sendingRSAPublicKey": { - "description": "Message for the rollout process", - "type": "text" - }, - "sendingRSAPublicKeyFailed": "Wysyłanie publicznego klucza RSA nieudane", - "@sendingRSAPublicKeyFailed": { - "description": "Message for the rollout process", - "type": "text" - }, - "parsingResponse": "Analizowanie odpowiedzi", - "@parsingResponse": { - "description": "Message for the rollout process", - "type": "text" - }, - "parsingResponseFailed": "Analizowanie odpowiedzi nieudane", - "@parsingResponseFailed": { - "description": "Message for the rollout process", - "type": "text" - }, - "rolloutCompleted": "Wdrożenie zakończone", - "@rolloutCompleted": { - "description": "Message for the rollout process", - "type": "text" - }, - "errorRollOutNoConnectionToServer": "Brak połączenia z serwerem", - "@errorRollOutNoConnectionToServer": { - "description": "Message for the rollout process", - "type": "text" - }, - "authToAcceptPushRequest": "Uwierzytelnij, aby zaakceptować żądanie push.", - "@authToAcceptPushRequest": { - "description": "Message for the rollout process", - "type": "text" - }, - "authToDeclinePushRequest": "Uwierzytelnij, aby odrzucić żądanie push.", - "@authToDeclinePushRequest": { - "description": "authToDeclinePushRequest", - "type": "text" - }, - "incomingAuthRequestError": "Wiadomość nie zawierała wymaganych danych lub dane były zniekształcone.", - "@incomingAuthRequestError": { - "description": "incomingAuthRequestError", - "type": "text" - }, - "imageUrl": "Adres URL obrazu", - "@imageUrl": { - "description": "imageUrl", - "type": "text" - }, - "errorRollOutSSLHandshakeFailed": "Uścisk dłoni SSL nie powiódł się. Rozwijanie nie jest możliwe.", - "@errorRollOutSSLHandshakeFailed": { - "description": "errorRollOutSSLHandshakeFailed", - "type": "text" - }, - "errorWhenPullingChallenges": "Wystąpił błąd podczas odpytywania o wyzwania {name}", - "@errorWhenPullingChallenges": { - "description": "errorWhenPullingChallenges", - "type": "text", - "placeholders": { - "name": { - "type": "String", - "example": "PUSH1234A" - } - } - }, - "errorRollOutTokenExpired": "Wstać z łóżka tego tokena nie jest już możliwe.\nToken {name} wygasł.", - "@errorRollOutTokenExpired": { - "description": "errorRollOutTokenExpired", - "type": "text", - "placeholders": { - "name": { - "example": "PUSH1234" - } - } - }, - "yes": "Tak", - "@yes": { - "description": "yes", - "type": "text" - }, - "no": "Nie", - "@no": { - "description": "no", - "type": "text" - }, - "butDiscardIt": "ale odrzucić go", - "@butDiscardIt": { - "description": "butDiscardIt", - "type": "text" - }, - "declineIt": "odrzuć go", - "@declineIt": { - "description": "declineIt", - "type": "text" - }, - "requestTriggerdByUserQuestion": "Czy ta prośba została wywołana przez Ciebie?", - "@requestTriggerdByUserQuestion": { - "description": "requestTriggerdByUserQuestion", - "type": "text" - } +{ + "@@last_modified": "2023-08-07", + "guide": "Przewodnik", + "@guide": { + "description": "Button to open the guide screen." + }, + "accept": "Potwierdzam", + "@accept": { + "description": "Label for e.g. a button. Something gets accepted by the user." + }, + "decline": "Odrzucam", + "@decline": { + "description": "Label for e.g. a button. Something gets declined by the user." + }, + "name": "Nazwa", + "@name": { + "description": "Describes the field where the tokens name should be entered." + }, + "secret": "Sekret", + "@secret": { + "description": "Describes the field where the tokens secret should be entered." + }, + "encoding": "Kodowanie", + "@encoding": { + "description": "Title of the dropdown button where the encoding is selected." + }, + "algorithm": "Algorytm", + "@algorithm": { + "description": "Title of the dropdown button where the encoding is selected." + }, + "digits": "Ilość cyfr", + "@digits": { + "description": "Title of the dropdown button where the number of digits for the opt value is selected." + }, + "type": "Typ", + "@type": { + "description": "Title of the dropdown button where the type of the token is selected." + }, + "period": "Cykl", + "@period": { + "description": "Title of the dropdown button where the period of the totp token is selected." + }, + "rename": "Zmień nazwę", + "@rename": { + "description": "Label that describes renaming the token." + }, + "cancel": "Anuluj", + "@cancel": { + "description": "Button to cancel an action." + }, + "delete": "Usuń", + "@delete": { + "description": "Label that describes deleting the token." + }, + "dismiss": "Odrzuć", + "@dismiss": { + "description": "Text of a button that closes a dialog." + }, + "addToken": "Dodaj token", + "@addToken": { + "description": "The button to open the screen to add tokens by hand." + }, + "scanQrCode": "Zeskanuj kod QR", + "@scanQrCode": { + "description": "The button to scan otpauth qr-codes." + }, + "enterDetailsForToken": "Wprowadź szczegóły dla tokenu", + "@enterDetailsForToken": { + "description": "Title of the screen where tokens are created manually, tells the user to enter all required values." + }, + "pleaseEnterANameForThisToken": "Wprowadź nazwę dla tokenu", + "@pleaseEnterANameForThisToken": { + "description": "Hint telling the user to enter a name for a token." + }, + "pleaseEnterASecretForThisToken": "Wprowadź sekret dla tokenu", + "@pleaseEnterASecretForThisToken": { + "description": "Hint telling the user to enter a secret for a token." + }, + "theSecretDoesNotFitTheCurrentEncoding": "Sekret nie odpowiada wybranemu sposobowi kodowania.", + "@theSecretDoesNotFitTheCurrentEncoding": { + "description": "Hint telling the user that the secret does not fit the selected encoding." + }, + "renameToken": "Zmień nazwę tokenu", + "@renameToken": { + "description": "Title of the dialog where a new name for a token can be entered." + }, + "confirmDeletion": "Potwierdź usunięcie", + "@confirmDeletion": { + "description": "Title of the dialog where a token can be deleted." + }, + "confirmDeletionOf": "Jesteś pewien, że chcesz usunąć token: {name}?", + "@confirmDeletionOf": { + "description": "Asks for confirmation on deleting a token.", + "type": "text", + "placeholders": { + "name": { + "example": "PUSH1234" + } + } + }, + "generatingPhonePart": "Generowanie sekretu po stronie telefonu...", + "@generatingPhonePart": { + "description": "Title of a dialog telling the user that the phone part gets generated right now." + }, + "phonePart": "Sekret po stronie telefonu:", + "@phonePart": { + "description": "Title of a dialog telling the user that the phone was generated, and it is shown to the user." + }, + "otpValueCopiedMessage": "Jednorazowe hasło \"{otpValue}\" skopiowane do schowka.", + "@otpValueCopiedMessage": { + "description": "Tells the user that the otp value was copied to the clipboard.", + "type": "text", + "placeholders": { + "otpValue": { + "example": "055374" + } + } + }, + "settings": "Ustawienia", + "@settings": { + "description": "Button to open the settings page." + }, + "pushToken": "Push token", + "@pushToken": { + "description": "Title for the settings block concerning the push tokens." + }, + "theme": "Motyw", + "@theme": { + "description": "Title of the setting group where the theme can be selected." + }, + "lightTheme": "Jasny", + "@lightTheme": { + "description": "The light theme." + }, + "darkTheme": "Ciemny", + "@darkTheme": { + "description": "The dark theme." + }, + "systemTheme": "Motyw systemu", + "@systemTheme": { + "description": "The systems theme." + }, + "enablePolling": "Włącz autentykację przez wiadomość push.", + "@enablePolling": { + "description": "Name of the setting switch that enables polling." + }, + "requestPushChallengesPeriodically": "Wysyłaj zapytanie o push challenge cyklicznie. Włącz, jeśli push nie przychodzi normalnie.", + "@requestPushChallengesPeriodically": { + "description": "The description of the polling feature." + }, + "synchronizePushTokens": "Synchronizuj tokeny push.", + "@synchronizePushTokens": { + "description": "Title of synchronizing push tokens in settings." + }, + "synchronizesTokensWithServer": "Synchronizuje tokeny push z serwerem privacyIDEA.", + "@synchronizesTokensWithServer": { + "description": "Description of synchronizing push tokens in settings." + }, + "sync": "Synchronizuj", + "@sync": { + "description": "Text of button that is used to synchronize push tokens." + }, + "synchronizingTokens": "Synchronizacja tokenów.", + "@synchronizingTokens": { + "description": "Title of the push synchronization dialog." + }, + "allTokensSynchronized": "Wszystkie tokeny są zsynchronizowane.", + "@allTokensSynchronized": { + "description": "Content of the push synchronization dialog. Signaling the user that everything worked." + }, + "synchronizationFailed": "Synchronizacja dla poniższych tokenów się nie udała, spróbuj ponownie:", + "@synchronizationFailed": { + "description": "Headline for the list of tokens where the synchronization failed." + }, + "tokensDoNotSupportSynchronization": "Następujące tokeny nie wspierają synchronizacji i muszą zostać wdrożone od nowa:", + "@tokensDoNotSupportSynchronization": { + "description": "Informs the user that the following tokens cannot be synchronized as they do not support that." + }, + "errorRollOutFailed": "Wdrażanie tokenu {name} nieudane. Kod błędu: {errorCode}", + "@errorRollOutFailed": { + "description": "Tells the user that the token could not be rolled out, because a network error occurred.", + "type": "text", + "placeholders": { + "name": { + "example": "PUSH1234A" + }, + "errorCode": { + "example": "500" + } + } + }, + "errorSynchronizationNoNetworkConnection": "Synchronizacja tokenów push nieudana, ponieważ serwer privacyIDEA jest nieosiągalny.", + "@errorSynchronizationNoNetworkConnection": { + "description": "Tells the user that synchronizing the push tokens failed because the server could not be reached." + }, + "errorRollOutUnknownError": "Napotkano nieznany błąd. Wdrożenie tokenu niemożliwe: {e}", + "@errorRollOutUnknownError": { + "description": "Tells the user that the roll-out failed because of an unknown error.", + "type": "text", + "placeholders": { + "e": { + "example": "IllegalArgumentException on Line 5 ..." + } + } + }, + "rollingOut": "Wdrażanie", + "@rollingOut": { + "description": "Label that tells the user that the token is being rolled out." + }, + "pollingChallenges": "Sprawdzanie nowych wyzwań", + "@pollingChallenges": { + "type": "text" + }, + "unexpectedError": "Wystąpił nieoczekiwany błąd.", + "@unexpectedError": { + "description": "Title of page report mode." + }, + "pollingFailNoNetworkConnection": "Serwer jest nieosiągalny.", + "@pollingFailNoNetworkConnection": { + "description": "Tells the user that the roll-out failed because no network connection is available." + }, + "useDeviceLocaleTitle": "Użyj języka urządzenia.", + "@useDeviceLocaleTitle": { + "description": "Title of the switch tile where using the devices language can be enabled." + }, + "useDeviceLocaleDescription": "Użyj języka urządzenia, jeśli jest wspierany. W innym wypadku zostanie ustawiony domyślny język angielski.", + "@useDeviceLocaleDescription": { + "description": "Description of the switch tile where using the devices language can be enabled." + }, + "language": "Język", + "@language": { + "description": "Title of language setting group." + }, + "authenticateToShowOtp": "Zweryfikuj tożsamość, by pokazać hasło jednorazowe.", + "@authenticateToShowOtp": { + "description": "Reason to authenticate when trying to view a one time password." + }, + "authenticateToUnLockToken": "Zweryfikuj tożsamość, aby odblokować / zablokować token.", + "@authenticateToUnLockToken": { + "description": "Reason to authenticate when trying to lock or unlock a token." + }, + "biometricRequiredTitle": "Uwierzytelnianie biometryczne nie jest skonfigurowane.", + "@biometricRequiredTitle": { + "description": "Message showed as a title in a dialog which indicates the user has not set up biometric authentication on their device. It is used on Android side. Maximum 60 characters." + }, + "biometricHint": "Wymagana autentykacja", + "@biometricHint": { + "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." + }, + "biometricNotRecognized": "Nie rozpoznano. Spróbuj ponownie.", + "@biometricNotRecognized": { + "description": "Message to let the user know that authentication was failed. It is used on Android side. Maximum 60 characters." + }, + "biometricSuccess": "Autentykacja zakończona sukcesem!", + "@biometricSuccess": { + "description": "Message to let the user know that authentication was successful. It is used on Android side. Maximum 60 characters." + }, + "deviceCredentialsRequiredTitle": "Ustawienia zabezpieczeń urządzenia nie zostały skonfigurowane.", + "@deviceCredentialsRequiredTitle": { + "description": "Message showed as a title in a dialog which indicates the user has not set up credentials authentication on their device. It is used on Android side. Maximum 60 characters." + }, + "deviceCredentialsSetupDescription": "Skonfiguruj ustawienia zabezpieczeń w ustawieniach urządzenia.", + "@deviceCredentialsSetupDescription": { + "description": "Message advising the user to go to the settings and configure device credentials on their device. It shows in a dialog on Android side." + }, + "signInTitle": "Wymagana autentykacja", + "@signInTitle": { + "description": "Message showed as a title in a dialog which indicates the user that they need to scan biometric to continue. It is used on Android side. Maximum 60 characters." + }, + "goToSettingsButton": "Idź do ustawień", + "@goToSettingsButton": { + "description": "Message showed on a button that the user can click to go to settings pages from the current dialog. It is used on both Android and iOS side. Maximum 30 characters." + }, + "goToSettingsDescription": "Ustawienia zabezpieczeń, bądź uwierzytelnianie biometryczne nie są skonfigurowane w twoim urządzeniu. Skonfiguruj je w ustawieniach urządzenia.", + "@goToSettingsDescription": { + "description": "Message advising the user to go to the settings and configure device credentials or biometrics on their device." + }, + "lockOut": "Uwierzytelnianie biometryczne jest wyłączone. Zablokuj i odblokuj ponownie ekran, żeby je włączyć.", + "@lockOut": { + "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." + }, + "authNotSupportedTitle": "Skonfigurowane ustawienia zabezpieczeń albo uwierzytelnianie biometryczne jest wymagane.", + "@authNotSupportedTitle": { + "description": "Message shown as a dialog title that tells the user that device credentials or biometrics must be setup for this action." + }, + "authNotSupportedBody": "To działanie wymaga skonfigurowania ustawień zabezpieczeń albo uwierzytelniania biometrycznego.", + "@authNotSupportedBody": { + "description": "Message shown as a dialog body that tells the user that device credentials or biometrics must be setup for this action." + }, + "lock": "Zablokuj", + "@lock": { + "description": "Description of button that locks a token." + }, + "unlock": "Odblokuj", + "@unlock": { + "description": "Description of button that unlocks a token." + }, + "noResultTitle": "Nie zainstalowano jeszcze żadnego tokenu.", + "@noResultTitle": { + "description": "No tokens installed yet." + }, + "noResultText1": "Dotknij ", + "@noResultText1": { + "description": "first noresult text" + }, + "noResultText2": " przycisku, żeby zacząć!", + "@noResultText2": { + "description": "second noresult text" + }, + "onBoardingTitle1": "{appName}", + "@onBoardingTitle1": { + "placeholders": { + "appName": { + "example": "privacyIDEA Authenticator" + } + } + }, + "onBoardingText1": "Uwierzytelnianie dwuskładnikowe\nuczynione prostym", + "onBoardingTitle2": "Maksymalne Bezpieczeństwo", + "onBoardingText2": "Przechowuj tokeny w swoim urządzeniu\nzabezpieczone biometrycznie", + "onBoardingTitle3": "Odwiedź nas na Github", + "onBoardingText3": "Ta aplikacja jest w open source", + "errorLogTitle": "Error logs", + "sendErrorHint": "Wyślij nam dziennik błędów pocztą e-mail", + "enableVerboseLogging": "Włącz szczegółowe rejestrowanie", + "clearErrorLogHint": "Czyści lokalny plik dziennika błędów", + "logMenu": "LogMenu", + "sendErrorDialogHeader": "Wyślij przez e-mail", + "ok": "Ok", + "noLogToSend": "There is log to send.", + "errorLogFileAttached": "Plik dziennika błędów jest dołączony.\nTekst ten można zastąpić dodatkowymi informacjami o błędzie.", + "@errorMailBody": { + "description": "Message for email body" + }, + "errorLogCleared": "Wyczyszczono dzienniki błędów", + "showDetails": "Pokaż szczegóły", + "open": "Otwórz", + "sendErrorDialogBody": "Napotkano nieoczekiwany błąd w aplikacji. Poniższa wiadomość może zostać wysłana do deweloperów poprzez email, żeby pomóc uniknąć tego problemu w przyszłości.", + "@sendErrorDialogBody": { + "description": "Description shown to the user about what info the error report contains." + }, + "noFbToken": "Brak dostępnego tokena Firebase", + "firebaseToken": "Token Firebase", + "noPublicKey": "Brak dostępnego klucza publicznego", + "publicKey": "Klucz publiczny", + "editToken": "Edytuj token", + "edit": "Edytuj", + "save": "Zapisz", + "validFor": "Ważny przez", + "validUntil": "Ważny do", + "deleteLockedToken": "Uwierzytelnij, aby usunąć zablokowany token.", + "editLockedToken": "Aby edytować zablokowany token, należy się uwierzytelnić.", + "uncollapseLockedFolder": "Uwierzytelnij, aby otworzyć zablokowany folder.", + "renameTokenFolder": "Zmiana nazwy folderu", + "addANewFolder": "Utwórz nowy folder", + "folderName": "Nazwa folderu", + "retryRollout": "Ponowne uruchomienie", + "generatingRSAKeyPair": "Generowanie pary kluczy RSA", + "@generatingRSAKeyPair": { + "description": "Message for the rollout process" + }, + "generatingRSAKeyPairFailed": "Generowanie pary kluczy RSA nieudane", + "@generatingRSAKeyPairFailed": { + "description": "Message for the rollout process" + }, + "sendingRSAPublicKey": "Wysyłanie publicznego klucza RSA", + "@sendingRSAPublicKey": { + "description": "Message for the rollout process" + }, + "sendingRSAPublicKeyFailed": "Wysyłanie publicznego klucza RSA nieudane", + "@sendingRSAPublicKeyFailed": { + "description": "Message for the rollout process" + }, + "parsingResponse": "Analizowanie odpowiedzi", + "@parsingResponse": { + "description": "Message for the rollout process" + }, + "parsingResponseFailed": "Analizowanie odpowiedzi nieudane", + "@parsingResponseFailed": { + "description": "Message for the rollout process" + }, + "rolloutCompleted": "Wdrożenie zakończone", + "@rolloutCompleted": { + "description": "Message for the rollout process" + }, + "errorRollOutNoConnectionToServer": "Brak połączenia z serwerem", + "@errorRollOutNoConnectionToServer": { + "description": "Message for the rollout process" + }, + "authToAcceptPushRequest": "Uwierzytelnij, aby zaakceptować żądanie push.", + "@authToAcceptPushRequest": { + "description": "Message for the rollout process" + }, + "authToDeclinePushRequest": "Uwierzytelnij, aby odrzucić żądanie push.", + "incomingAuthRequestError": "Wiadomość nie zawierała wymaganych danych lub dane były zniekształcone.", + "imageUrl": "Adres URL obrazu", + "errorRollOutSSLHandshakeFailed": "Uścisk dłoni SSL nie powiódł się. Rozwijanie nie jest możliwe.", + "errorWhenPullingChallenges": "Wystąpił błąd podczas odpytywania o wyzwania {name}", + "@errorWhenPullingChallenges": { + "placeholders": { + "name": { + "example": "PUSH1234A" + } + } + }, + "errorRollOutTokenExpired": "Wstać z łóżka tego tokena nie jest już możliwe.\nToken {name} wygasł.", + "@errorRollOutTokenExpired": { + "placeholders": { + "name": { + "example": "PUSH1234" + } + } + }, + "yes": "Tak", + "no": "Nie", + "butDiscardIt": "ale odrzucić go", + "declineIt": "odrzuć go", + "requestTriggerdByUserQuestion": "Czy ta prośba została wywołana przez Ciebie?", + "grantCameraPermissionDialogTitle": "Uprawnienie do kamery nie zostało przyznane", + "grantCameraPermissionDialogContent": "Przyznaj uprawnienia kamery do skanowania kodów QR.", + "grantCameraPermissionDialogPermanentlyDenied": "Uprawnienia do aparatu zostały trwale zablokowane. Przyznaj uprawnienia aparatu w ustawieniach telefonu.", + "grantCameraPermissionDialogButton": "Grant permission", + "decryptErrorTitle": "Decryption error", + "decryptErrorContent": "Niestety, aplikacja nie była w stanie odszyfrować tokenów. Oznacza to, że klucz szyfrowania jest uszkodzony. Możesz spróbować ponownie lub usunąć dane aplikacji, co spowoduje usunięcie tokenów w aplikacji.", + "decryptErrorButtonDelete": "Usuń", + "decryptErrorButtonSendError": "Wyślij błąd", + "decryptErrorButtonRetry": "Ponów próbę", + "decryptErrorDeleteConfirmationContent": "Czy na pewno chcesz usunąć dane aplikacji?", + "hidePushTokens": "Ukryj tokeny push", + "hidePushTokensDescription": "Ukryj tokeny push z listy tokenów. Nie spowoduje to usunięcia tokenów i będą one nadal widoczne na osobnym ekranie", + "licensesAndVersion": "Licencje i wersja" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index 0c6f2ff68..000000000 --- a/lib/main.dart +++ /dev/null @@ -1,156 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'package:easy_dynamic_theme/easy_dynamic_theme.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacyidea_authenticator/model/platform_info/platform_info_imp/package_info_plus_platform_info.dart'; -import 'package:privacyidea_authenticator/utils/app_customizer.dart'; -import 'package:privacyidea_authenticator/utils/customizations.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; -import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; -import 'package:privacyidea_authenticator/utils/themes.dart'; -import 'package:privacyidea_authenticator/views/add_token_manually_view/add_token_manually_view.dart'; -import 'package:privacyidea_authenticator/views/main_view/main_view.dart'; -import 'package:privacyidea_authenticator/views/onboarding_view/onboarding_view.dart'; -import 'package:privacyidea_authenticator/views/qr_scanner_view/scanner_view.dart'; -import 'package:privacyidea_authenticator/views/settings_view/settings_view.dart'; - -void main() async { - Logger.init( - navigatorKey: globalNavigatorKey, - appRunner: () async { - WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); - await Firebase.initializeApp(); - runApp(const AppWrapper(child: PrivacyIDEAAuthenticator())); - }); -} - -class PrivacyIDEAAuthenticator extends ConsumerWidget { - const PrivacyIDEAAuthenticator({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - globalRef = ref; - final state = ref.watch(settingsProvider); - final locale = state.currentLocale; - return MaterialApp( - debugShowCheckedModeBanner: true, - navigatorKey: globalNavigatorKey, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - locale: locale, - title: ApplicationCustomizer.appName, - theme: lightThemeData, - darkTheme: darkThemeData, - scaffoldMessengerKey: globalSnackbarKey, // <= this - themeMode: EasyDynamicTheme.of(context).themeMode, - initialRoute: SplashScreen.routeName, - routes: { - SplashScreen.routeName: (context) => const SplashScreen(), - OnboardingView.routeName: (context) => const OnboardingView(), - MainView.routeName: (context) => const MainView(title: ApplicationCustomizer.appName), - SettingsView.routeName: (context) => const SettingsView(), - AddTokenManuallyView.routeName: (context) => const AddTokenManuallyView(), - QRScannerView.routeName: (context) => QRScannerView(), - }, - ); - } -} - -class AppWrapper extends StatelessWidget { - final Widget child; - - const AppWrapper({Key? key, required this.child}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ProviderScope( - child: EasyDynamicThemeWidget(child: child), - ); - } -} - -class SplashScreen extends ConsumerStatefulWidget { - static const routeName = '/'; - - const SplashScreen({super.key}); - - @override - ConsumerState createState() => _SplashScreenState(); -} - -class _SplashScreenState extends ConsumerState { - var _appIconIsVisible = false; - final _splashScreenDuration = const Duration(milliseconds: 400); - final _splashScreenDelay = const Duration(milliseconds: 250); - - @override - void initState() { - super.initState(); - Logger.info('Starting app.', name: 'main.dart#initState'); - Future.delayed(_splashScreenDelay, () { - if (mounted) { - setState(() { - _appIconIsVisible = true; - }); - } - }); - _init(); - } - - Future _init() async { - ref.read(platformInfoProvider.notifier).state = await PackageInfoPlusPlatformInfo.loadInfos(); - await Future.delayed(_splashScreenDuration + _splashScreenDelay * 2); - final isFirstRun = ref.read(settingsProvider).isFirstRun; - final ConsumerStatefulWidget nextView; - if (isFirstRun) { - nextView = const OnboardingView(); - } else { - nextView = const MainView( - title: ApplicationCustomizer.appName, - ); - } - // ignore: use_build_context_synchronously - Navigator.pushReplacement( - context, - PageRouteBuilder( - pageBuilder: (_, __, ___) => nextView, - transitionDuration: _splashScreenDuration * 2, - transitionsBuilder: (_, a, __, c) => FadeTransition(opacity: a, child: c), - ), - ); - } - - @override - Widget build(BuildContext context) => Scaffold( - body: Center( - child: AnimatedOpacity( - opacity: _appIconIsVisible ? 1.0 : 0.0, - duration: _splashScreenDuration, - child: Image.asset(ApplicationCustomizer.appIcon), - ), - ), - ); -} diff --git a/lib/main_customizer.dart b/lib/main_customizer.dart new file mode 100644 index 000000000..5da559ad2 --- /dev/null +++ b/lib/main_customizer.dart @@ -0,0 +1,94 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import 'package:easy_dynamic_theme/easy_dynamic_theme.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; +import 'package:privacyidea_authenticator/utils/customizations.dart'; +import 'package:privacyidea_authenticator/utils/logger.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/views/add_token_manually_view/add_token_manually_view.dart'; +import 'package:privacyidea_authenticator/views/license_view/license_view.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view.dart'; +import 'package:privacyidea_authenticator/views/onboarding_view/onboarding_view.dart'; +import 'package:privacyidea_authenticator/views/qr_scanner_view/qr_scanner_view.dart'; +import 'package:privacyidea_authenticator/views/settings_view/settings_view.dart'; +import 'package:privacyidea_authenticator/views/splash_screen/splash_screen.dart'; +import 'package:privacyidea_authenticator/widgets/app_wrapper.dart'; + +void main() async { + Logger.init( + navigatorKey: globalNavigatorKey, + appRunner: () async { + WidgetsFlutterBinding.ensureInitialized(); + if (!kIsWeb) await Firebase.initializeApp(); + runApp(const AppWrapper(child: CustomizationAuthenticator())); + }); +} + +class CustomizationAuthenticator extends ConsumerWidget { + const CustomizationAuthenticator({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + WidgetsFlutterBinding.ensureInitialized(); + globalRef = ref; + final state = ref.watch(settingsProvider); + final locale = state.currentLocale; + final applicationCustomizer = ref.read(applicationCustomizerProvider); + return MaterialApp( + debugShowCheckedModeBanner: true, + navigatorKey: globalNavigatorKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: locale, + title: applicationCustomizer.appName, + theme: applicationCustomizer.generateLightTheme(), + darkTheme: applicationCustomizer.generateDarkTheme(), + scaffoldMessengerKey: globalSnackbarKey, // <= this + themeMode: EasyDynamicTheme.of(context).themeMode, + initialRoute: SplashScreen.routeName, + routes: { + SplashScreen.routeName: (context) => SplashScreen( + appIcon: applicationCustomizer.appIcon, + appImage: applicationCustomizer.appImage, + appName: applicationCustomizer.appName, + ), + OnboardingView.routeName: (context) => OnboardingView( + appName: applicationCustomizer.appName, + ), + MainView.routeName: (context) => MainView( + appIcon: applicationCustomizer.appIcon, + appName: applicationCustomizer.appName, + ), + SettingsView.routeName: (context) => const SettingsView(), + AddTokenManuallyView.routeName: (context) => const AddTokenManuallyView(), + QRScannerView.routeName: (context) => const QRScannerView(), + LicenseView.routeName: (context) => LicenseView( + appImage: applicationCustomizer.appImage, + appName: applicationCustomizer.appName, + websiteLink: applicationCustomizer.websiteLink, + ), + }, + ); + } +} diff --git a/lib/main_netknights.dart b/lib/main_netknights.dart new file mode 100644 index 000000000..05f2621c0 --- /dev/null +++ b/lib/main_netknights.dart @@ -0,0 +1,96 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import 'package:easy_dynamic_theme/easy_dynamic_theme.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; +import 'package:privacyidea_authenticator/utils/app_customizer.dart'; +import 'package:privacyidea_authenticator/utils/customizations.dart'; +import 'package:privacyidea_authenticator/utils/logger.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/views/add_token_manually_view/add_token_manually_view.dart'; +import 'package:privacyidea_authenticator/views/license_view/license_view.dart'; +import 'package:privacyidea_authenticator/views/main_view/main_view.dart'; +import 'package:privacyidea_authenticator/views/onboarding_view/onboarding_view.dart'; +import 'package:privacyidea_authenticator/views/push_token_view/push_tokens_view.dart'; +import 'package:privacyidea_authenticator/views/qr_scanner_view/qr_scanner_view.dart'; +import 'package:privacyidea_authenticator/views/settings_view/settings_view.dart'; +import 'package:privacyidea_authenticator/views/splash_screen/splash_screen.dart'; +import 'package:privacyidea_authenticator/widgets/app_wrapper.dart'; + +void main() async { + Logger.init( + navigatorKey: globalNavigatorKey, + appRunner: () async { + WidgetsFlutterBinding.ensureInitialized(); + if (!kIsWeb) await Firebase.initializeApp(); + runApp(AppWrapper(child: PrivacyIDEAAuthenticator(customization: ApplicationCustomization.defaultCustomization))); + }); +} + +class PrivacyIDEAAuthenticator extends ConsumerWidget { + final ApplicationCustomization customization; + const PrivacyIDEAAuthenticator({required this.customization, super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + WidgetsFlutterBinding.ensureInitialized(); + globalRef = ref; + final locale = ref.watch(settingsProvider).currentLocale; + return MaterialApp( + debugShowCheckedModeBanner: true, + navigatorKey: globalNavigatorKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: locale, + title: customization.appName, + theme: customization.generateLightTheme(), + darkTheme: customization.generateDarkTheme(), + scaffoldMessengerKey: globalSnackbarKey, // <= this + themeMode: EasyDynamicTheme.of(context).themeMode, + initialRoute: SplashScreen.routeName, + routes: { + SplashScreen.routeName: (context) => SplashScreen( + appImage: customization.appImage, + appIcon: customization.appIcon, + appName: customization.appName, + ), + OnboardingView.routeName: (context) => OnboardingView( + appName: customization.appName, + ), + MainView.routeName: (context) => MainView( + appIcon: customization.appIcon, + appName: customization.appName, + ), + SettingsView.routeName: (context) => const SettingsView(), + AddTokenManuallyView.routeName: (context) => const AddTokenManuallyView(), + QRScannerView.routeName: (context) => const QRScannerView(), + LicenseView.routeName: (context) => LicenseView( + appImage: customization.appImage, + appName: customization.appName, + websiteLink: customization.websiteLink, + ), + PushTokensView.routeName: (context) => const PushTokensView(), + }, + ); + } +} diff --git a/lib/model/platform_info/platform_info.dart b/lib/model/platform_info/platform_info.dart deleted file mode 100644 index 6a1487c9b..000000000 --- a/lib/model/platform_info/platform_info.dart +++ /dev/null @@ -1,7 +0,0 @@ -abstract class PlatformInfo { - String get appName; - String get packageName; - String get appVersion; - String get buildNumber; - String get installerStore; -} diff --git a/lib/model/platform_info/platform_info_imp/dummy_platform_info.dart b/lib/model/platform_info/platform_info_imp/dummy_platform_info.dart deleted file mode 100644 index b43535218..000000000 --- a/lib/model/platform_info/platform_info_imp/dummy_platform_info.dart +++ /dev/null @@ -1,21 +0,0 @@ -import '../platform_info.dart'; - -/// This class is used as a Placeholder for the PlatformInfos while the real infos are not yet loaded. -class DummyPlatformInfo extends PlatformInfo { - @override - String get appName => 'Dummy App'; - - @override - String get buildNumber => '1'; - - @override - String get installerStore => 'Dummy Store'; - - @override - String get packageName => 'Dummy Package'; - - @override - String get appVersion => '1.0.0'; - - DummyPlatformInfo(); -} diff --git a/lib/model/platform_info/platform_info_imp/package_info_plus_platform_info.dart b/lib/model/platform_info/platform_info_imp/package_info_plus_platform_info.dart deleted file mode 100644 index bf8968535..000000000 --- a/lib/model/platform_info/platform_info_imp/package_info_plus_platform_info.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:package_info_plus/package_info_plus.dart'; - -import '../platform_info.dart'; - -class PackageInfoPlusPlatformInfo extends PlatformInfo { - final PackageInfo _packageInfo; - - PackageInfoPlusPlatformInfo(this._packageInfo); - - @override - String get appName => _packageInfo.appName; - - @override - String get buildNumber => _packageInfo.buildNumber; - - @override - String get installerStore => _packageInfo.installerStore ?? 'Not available'; - - @override - String get packageName => _packageInfo.packageName; - - @override - String get appVersion => _packageInfo.version; - - static Future loadInfos() async { - final packageInfo = await PackageInfo.fromPlatform(); - return PackageInfoPlusPlatformInfo(packageInfo); - } -} diff --git a/lib/model/push_request.dart b/lib/model/push_request.dart index 67a0dc768..6ca32e0e6 100644 --- a/lib/model/push_request.dart +++ b/lib/model/push_request.dart @@ -1,5 +1,4 @@ import 'package:json_annotation/json_annotation.dart'; -import '../utils/riverpod_providers.dart'; part 'push_request.g.dart'; @@ -16,7 +15,7 @@ class PushRequest { final String signature; final bool? accepted; - PushRequest({ + const PushRequest({ required this.title, required this.question, required this.uri, @@ -28,10 +27,7 @@ class PushRequest { String? signature, this.accepted, }) : serial = serial ?? '', - signature = signature ?? '' { - int time = expirationDate.difference(DateTime.now()).inMilliseconds; - Future.delayed(Duration(milliseconds: time < 1 ? 1 : time), () => globalRef?.read(tokenProvider.notifier).removePushRequest(this)); - } + signature = signature ?? ''; PushRequest copyWith({ String? title, diff --git a/lib/model/serializable_RSA_private_key.dart b/lib/model/serializable_RSA_private_key.dart index 84b0e22dc..a83f338d1 100644 --- a/lib/model/serializable_RSA_private_key.dart +++ b/lib/model/serializable_RSA_private_key.dart @@ -7,7 +7,7 @@ part 'serializable_RSA_private_key.g.dart'; @JsonSerializable() class SerializableRSAPrivateKey extends RSAPrivateKey { - SerializableRSAPrivateKey(BigInt modulus, BigInt exponent, BigInt p, BigInt q) : super(modulus, exponent, p, q); + SerializableRSAPrivateKey(super.modulus, super.exponent, BigInt super.p, BigInt super.q); factory SerializableRSAPrivateKey.fromJson(Map json) => _$SerializableRSAPrivateKeyFromJson(json); diff --git a/lib/model/serializable_RSA_public_key.dart b/lib/model/serializable_RSA_public_key.dart index 891c344cc..3096d6a6a 100644 --- a/lib/model/serializable_RSA_public_key.dart +++ b/lib/model/serializable_RSA_public_key.dart @@ -7,7 +7,7 @@ part 'serializable_RSA_public_key.g.dart'; @JsonSerializable() class SerializableRSAPublicKey extends RSAPublicKey { - SerializableRSAPublicKey(BigInt modulus, BigInt exponent) : super(modulus, exponent); + SerializableRSAPublicKey(super.modulus, super.exponent); factory SerializableRSAPublicKey.fromJson(Map json) => _$SerializableRSAPublicKeyFromJson(json); diff --git a/lib/model/states/settings_state.dart b/lib/model/states/settings_state.dart index cf1e0335e..73312e0e9 100644 --- a/lib/model/states/settings_state.dart +++ b/lib/model/states/settings_state.dart @@ -1,8 +1,9 @@ import 'dart:io'; import 'dart:ui'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter/foundation.dart'; +import '../../l10n/app_localizations.dart'; import '../../utils/identifiers.dart'; /// This class contains all device specific settings. E.g., the language used, whether to show the guide on start, etc. @@ -12,11 +13,12 @@ class SettingsState { static bool get _hideOtpsDefault => false; static bool get _enablePollDefault => false; static Set get _crashReportRecipientsDefault => {defaultCrashReportRecipient}; - static Locale get _localePreferenceDefault => - AppLocalizations.supportedLocales.firstWhere((locale) => locale.languageCode == Platform.localeName.substring(0, 2), orElse: () => const Locale('en')); + static Locale get _localePreferenceDefault => AppLocalizations.supportedLocales + .firstWhere((locale) => locale.languageCode == (!kIsWeb ? Platform.localeName.substring(0, 2) : 'en'), orElse: () => const Locale('en')); static bool get _useSystemLocaleDefault => true; static bool get _enableLoggingDefault => false; + static HidePushTokens get _hidePushTokensStateDefault => HidePushTokens.notHidden; final bool isFirstRun; final bool showGuideOnStart; @@ -25,10 +27,13 @@ class SettingsState { final Set crashReportRecipients; final Locale localePreference; Locale get currentLocale => useSystemLocale - ? AppLocalizations.supportedLocales.firstWhere((locale) => locale.languageCode == Platform.localeName.substring(0, 2), orElse: () => const Locale('en')) + ? AppLocalizations.supportedLocales + .firstWhere((locale) => locale.languageCode == (!kIsWeb ? Platform.localeName.substring(0, 2) : 'en'), orElse: () => const Locale('en')) : localePreference; final bool useSystemLocale; final bool verboseLogging; + final HidePushTokens hidePushTokensState; + bool get hidePushTokens => hidePushTokensState != HidePushTokens.notHidden; SettingsState({ bool? isFirstRun, @@ -39,6 +44,7 @@ class SettingsState { Locale? localePreference, bool? useSystemLocale, bool? verboseLogging, + HidePushTokens? hidePushTokensState, }) : isFirstRun = isFirstRun ?? _isFirstRunDefault, showGuideOnStart = showGuideOnStart ?? _showGuideOnStartDefault, hideOpts = hideOpts ?? _hideOtpsDefault, @@ -46,7 +52,8 @@ class SettingsState { crashReportRecipients = crashReportRecipients ?? _crashReportRecipientsDefault, localePreference = localePreference ?? _localePreferenceDefault, useSystemLocale = useSystemLocale ?? _useSystemLocaleDefault, - verboseLogging = verboseLogging ?? _enableLoggingDefault; + verboseLogging = verboseLogging ?? _enableLoggingDefault, + hidePushTokensState = hidePushTokensState ?? _hidePushTokensStateDefault; SettingsState copyWith({ bool? isFirstRun, @@ -57,6 +64,7 @@ class SettingsState { Locale? localePreference, bool? useSystemLocale, bool? verboseLogging, + HidePushTokens? hidePushTokensState, }) { return SettingsState( isFirstRun: isFirstRun ?? this.isFirstRun, @@ -67,12 +75,14 @@ class SettingsState { localePreference: localePreference ?? this.localePreference, useSystemLocale: useSystemLocale ?? this.useSystemLocale, verboseLogging: verboseLogging ?? this.verboseLogging, + hidePushTokensState: hidePushTokensState ?? this.hidePushTokensState, ); } @override - String toString() => - 'SettingsState(isFirstRun: $isFirstRun, showGuideOnStart: $showGuideOnStart, hideOpts: $hideOpts, enablePolling: $enablePolling, crashReportRecipients: $crashReportRecipients, localePreference: $localePreference, useSystemLocale: $useSystemLocale, verboseLogging: $verboseLogging)'; + String toString() => 'SettingsState(isFirstRun: $isFirstRun, showGuideOnStart: $showGuideOnStart, hideOpts: $hideOpts, enablePolling: $enablePolling, ' + 'crashReportRecipients: $crashReportRecipients, localePreference: $localePreference, useSystemLocale: $useSystemLocale, verboseLogging: $verboseLogging, ' + 'hidePushTokensState: $hidePushTokensState)'; static String encodeLocale(Locale locale) { return '${locale.languageCode}#${locale.countryCode}'; @@ -91,7 +101,8 @@ class SettingsState { other.crashReportRecipients.toString() == crashReportRecipients.toString() && other.localePreference.toString() == localePreference.toString() && other.useSystemLocale == useSystemLocale && - other.verboseLogging == verboseLogging; + other.verboseLogging == verboseLogging && + other.hidePushTokensState == hidePushTokensState; } static Locale decodeLocale(String str) { @@ -99,3 +110,9 @@ class SettingsState { return split[1] == 'null' ? Locale(split[0]) : Locale(split[0], split[1]); } } + +enum HidePushTokens { + notHidden, + isHiddenNotNoticed, + isHiddenAndNoticed, +} diff --git a/lib/model/states/token_folder_state.dart b/lib/model/states/token_folder_state.dart index 986637f0a..f4e2cf683 100644 --- a/lib/model/states/token_folder_state.dart +++ b/lib/model/states/token_folder_state.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import '../token_folder.dart'; @@ -10,35 +10,46 @@ class TokenFolderState { const TokenFolderState({required this.folders}); - TokenFolderState copyWith({List? folders}) { - return TokenFolderState( - folders: folders ?? this.folders, - ); - } - TokenFolderState withFolder(String name) { final newFolders = List.from(folders); newFolders.add(TokenFolder(label: name, folderId: newFolderId)); - return copyWith(folders: newFolders); + return TokenFolderState(folders: newFolders); } // replace all folders where the folderid is the same - TokenFolderState withUpdated({List? folders}) { + // if the folderid is none, add it to the list + TokenFolderState withUpdated(List folders) { final newFolders = List.from(this.folders); - folders?.forEach((newFolder) { + for (var newFolder in folders) { final index = newFolders.indexWhere((oldFolder) => oldFolder.folderId == newFolder.folderId); if (index != -1) { newFolders[index] = newFolder; } - }); - return copyWith(folders: newFolders); + } + return TokenFolderState(folders: newFolders); } TokenFolderState withoutFolder(TokenFolder folder) { final newFolders = List.from(folders); newFolders.removeWhere((element) => element.folderId == folder.folderId); - return copyWith(folders: newFolders); + return TokenFolderState(folders: newFolders); + } + + TokenFolderState withoutFolders(List folders) { + final newFolders = List.from(this.folders); + newFolders.removeWhere((element) => folders.any((folder) => folder.folderId == element.folderId)); + return TokenFolderState(folders: newFolders); } + @override + bool operator ==(Object other) => + identical(this, other) || other is TokenFolderState && runtimeType == other.runtimeType && listEquals(folders, other.folders); + + @override + int get hashCode => (folders.hashCode + runtimeType.hashCode).hashCode; + + @override + String toString() => 'TokenFolderState{folders: $folders}'; + get newFolderId => folders.fold(0, (previousValue, element) => max(previousValue, element.folderId)) + 1; } diff --git a/lib/model/states/token_state.dart b/lib/model/states/token_state.dart index d5b0a5170..5f9112081 100644 --- a/lib/model/states/token_state.dart +++ b/lib/model/states/token_state.dart @@ -1,13 +1,22 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; +import '../../utils/logger.dart'; import '../token_folder.dart'; +import '../tokens/hotp_token.dart'; +import '../tokens/push_token.dart'; import '../tokens/token.dart'; @immutable class TokenState { final List tokens; + + List get hotpTokens => tokens.whereType().toList(); + bool get hasHOTPTokens => hotpTokens.isNotEmpty; + + List get pushTokens => tokens.whereType().toList(); + bool get hasPushTokens => pushTokens.isNotEmpty; + TokenState({List tokens = const []}) : tokens = List.from(tokens) { _sort(this.tokens); } @@ -17,6 +26,8 @@ class TokenState { tokens.sort((a, b) => (a.sortIndex ?? double.infinity).compareTo(b.sortIndex ?? double.infinity)); } + T? currentOf(T token) => tokens.firstWhereOrNull((element) => element.id == token.id) as T?; + TokenState withToken(Token token) { final newTokens = List.from(tokens); newTokens.add(token); @@ -31,34 +42,89 @@ class TokenState { TokenState withoutToken(Token token) { final newTokens = List.from(tokens); - newTokens.remove(token); + newTokens.removeWhere((element) => element.id == token.id); return TokenState(tokens: newTokens); } TokenState withoutTokens(List tokens) { final newTokens = List.from(this.tokens); - newTokens.removeWhere((element) => tokens.contains(element)); + newTokens.removeWhere((element) => tokens.any((token) => token.id == element.id)); + return TokenState(tokens: newTokens); + } + + // Add a token if it does not exist yet + // Replace the token if it does exist + TokenState addOrReplaceToken(Token token) { + final newTokens = List.from(tokens); + final index = newTokens.indexWhere((element) => element.id == token.id); + if (index == -1) { + newTokens.add(token); + } else { + newTokens[index] = token; + } return TokenState(tokens: newTokens); } - TokenState updateToken(Token token) { + // Replace the token if it does exist + // Do nothing if it does not exist + TokenState replaceToken(Token token) { final newTokens = List.from(tokens); final index = newTokens.indexWhere((element) => element.id == token.id); - if (index == -1) return this; + if (index == -1) { + Logger.warning('Tried to replace a token that does not exist.', name: 'token_state.dart#replaceToken'); + return this; + } newTokens[index] = token; return TokenState(tokens: newTokens); } - TokenState updateTokens(List tokens) { + // replace all tokens where the id is the same + // if the id is none, add it to the list + TokenState addOrReplaceTokens(List tokens) { final newTokens = List.from(this.tokens); for (var token in tokens) { final index = newTokens.indexWhere((element) => element.id == token.id); + if (index == -1) { + newTokens.add(token); + continue; + } newTokens[index] = token; } return TokenState(tokens: newTokens); } - List tokensInFolder(TokenFolder folder) => tokens.where((token) => token.folderId == folder.folderId).toList(); - List tokensWithoutFolder() => tokens.where((token) => token.folderId == null).toList(); + // Replace the tokens if it does exist + // Do nothing if it does not exist + TokenState replaceTokens(List tokens) { + final newTokens = List.from(this.tokens); + for (var token in tokens) { + final index = newTokens.indexWhere((element) => element.id == token.id); + if (index == -1) { + Logger.warning('Tried to replace a token that does not exist.', name: 'token_state.dart#replaceToken'); + continue; + } + newTokens[index] = token; + } + return TokenState(tokens: newTokens); + } + + List tokensInFolder(TokenFolder folder, {List? only, List? exclude}) => tokens.where((token) { + if (token.folderId != folder.folderId) { + return false; + } + if (exclude != null && exclude.contains(token.runtimeType)) return false; + if (only != null && !only.contains(token.runtimeType)) return false; + return true; + }).toList(); + + List tokensWithoutFolder({List? only, List? exclude}) => tokens.where((token) { + if (token.folderId != null) { + return false; + } + if (exclude != null && exclude.contains(token.runtimeType)) return false; + if (only != null && !only.contains(token.runtimeType)) return false; + return true; + }).toList(); + PushToken? tokenWithPushRequest() => tokens.whereType().firstWhereOrNull((token) => token.pushRequests.isNotEmpty); } diff --git a/lib/model/token_folder.dart b/lib/model/token_folder.dart index 1cdca76ee..745240c1a 100644 --- a/lib/model/token_folder.dart +++ b/lib/model/token_folder.dart @@ -40,6 +40,15 @@ class TokenFolder with SortableMixin { ); } + @override + bool operator ==(Object other) => identical(this, other) || other is TokenFolder && folderId == other.folderId; + + @override + int get hashCode => (folderId.hashCode + runtimeType.hashCode).hashCode; + + @override + String toString() => 'TokenFolder{label: $label, folderId: $folderId, isExpanded: $isExpanded, isLocked: $isLocked, sortIndex: $sortIndex}'; + factory TokenFolder.fromJson(Map json) { var tokenFolder = _$TokenFolderFromJson(json); if (tokenFolder.isLocked) tokenFolder = tokenFolder.copyWith(isExpanded: false); diff --git a/lib/model/tokens/day_password_token.dart b/lib/model/tokens/day_password_token.dart index 9763d7ecc..8e989c250 100644 --- a/lib/model/tokens/day_password_token.dart +++ b/lib/model/tokens/day_password_token.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; -// ignore: library_prefixes -import 'package:otp/otp.dart' as OTPLibrary; +import 'package:otp/otp.dart' as otp_library; import 'package:uuid/uuid.dart'; +import '../../utils/crypto_utils.dart'; import '../../utils/identifiers.dart'; import '../../utils/utils.dart'; import 'otp_token.dart'; @@ -68,7 +68,7 @@ class DayPasswordToken extends OTPToken { ); @override - String get otpValue => OTPLibrary.OTP.generateTOTPCodeString( + String get otpValue => otp_library.OTP.generateTOTPCodeString( secret, DateTime.now().millisecondsSinceEpoch, length: digits, diff --git a/lib/model/tokens/hotp_token.dart b/lib/model/tokens/hotp_token.dart index 684f7ff2d..a383827c0 100644 --- a/lib/model/tokens/hotp_token.dart +++ b/lib/model/tokens/hotp_token.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -// ignore: library_prefixes -import 'package:otp/otp.dart' as OTPLibrary; +import 'package:otp/otp.dart' as otp_library; import 'package:uuid/uuid.dart'; +import '../../utils/crypto_utils.dart'; import '../../utils/identifiers.dart'; import '../../utils/utils.dart'; import 'otp_token.dart'; @@ -30,7 +30,7 @@ class HOTPToken extends OTPToken { }) : super(type: enumAsString(TokenTypes.HOTP)); @override - String get otpValue => OTPLibrary.OTP.generateHOTPCodeString( + String get otpValue => otp_library.OTP.generateHOTPCodeString( secret, counter, length: digits, @@ -87,7 +87,7 @@ class HOTPToken extends OTPToken { algorithm: mapStringToAlgorithm(uriMap[URI_ALGORITHM] ?? 'SHA1'), digits: uriMap[URI_DIGITS] ?? 6, secret: encodeSecretAs(uriMap[URI_SECRET], Encodings.base32), - counter: uriMap[URI_COUNTER], + counter: uriMap[URI_COUNTER] ?? 0, tokenImage: uriMap[URI_IMAGE], pin: uriMap[URI_PIN], isLocked: uriMap[URI_PIN], diff --git a/lib/model/tokens/push_token.dart b/lib/model/tokens/push_token.dart index 64b4bf363..749d68f84 100644 --- a/lib/model/tokens/push_token.dart +++ b/lib/model/tokens/push_token.dart @@ -5,7 +5,7 @@ import 'package:uuid/uuid.dart'; import '../../utils/custom_int_buffer.dart'; import '../../utils/identifiers.dart'; -import '../../utils/parsing_utils.dart'; +import '../../utils/rsa_utils.dart'; import '../../utils/utils.dart'; import '../push_request.dart'; import '../push_request_queue.dart'; @@ -15,6 +15,7 @@ part 'push_token.g.dart'; @JsonSerializable() class PushToken extends Token { + static RsaUtils rsaParser = const RsaUtils(); final DateTime? expirationDate; final String serial; @@ -31,12 +32,12 @@ class PushToken extends Token { final String? publicTokenKey; // Custom getter and setter for RSA keys - RSAPublicKey? get rsaPublicServerKey => publicServerKey == null ? null : deserializeRSAPublicKeyPKCS1(publicServerKey!); - PushToken withPublicServerKey(RSAPublicKey key) => copyWith(publicServerKey: serializeRSAPublicKeyPKCS1(key)); - RSAPublicKey? get rsaPublicTokenKey => publicTokenKey == null ? null : deserializeRSAPublicKeyPKCS1(publicTokenKey!); - PushToken withPublicTokenKey(RSAPublicKey key) => copyWith(publicTokenKey: serializeRSAPublicKeyPKCS1(key)); - RSAPrivateKey? get rsaPrivateTokenKey => privateTokenKey == null ? null : deserializeRSAPrivateKeyPKCS1(privateTokenKey!); - PushToken withPrivateTokenKey(RSAPrivateKey key) => copyWith(privateTokenKey: serializeRSAPrivateKeyPKCS1(key)); + RSAPublicKey? get rsaPublicServerKey => publicServerKey == null ? null : rsaParser.deserializeRSAPublicKeyPKCS1(publicServerKey!); + PushToken withPublicServerKey(RSAPublicKey key) => copyWith(publicServerKey: rsaParser.serializeRSAPublicKeyPKCS1(key)); + RSAPublicKey? get rsaPublicTokenKey => publicTokenKey == null ? null : rsaParser.deserializeRSAPublicKeyPKCS1(publicTokenKey!); + PushToken withPublicTokenKey(RSAPublicKey key) => copyWith(publicTokenKey: rsaParser.serializeRSAPublicKeyPKCS1(key)); + RSAPrivateKey? get rsaPrivateTokenKey => privateTokenKey == null ? null : rsaParser.deserializeRSAPrivateKeyPKCS1(privateTokenKey!); + PushToken withPrivateTokenKey(RSAPrivateKey key) => copyWith(privateTokenKey: rsaParser.serializeRSAPrivateKeyPKCS1(key)); PushToken withPushRequest(PushRequest pr) { pushRequests.add(pr); @@ -61,12 +62,12 @@ class PushToken extends Token { PushToken({ required this.serial, - required this.expirationDate, required super.label, required super.issuer, required super.id, - this.enrollmentCredentials, this.url, + this.expirationDate, + this.enrollmentCredentials, this.publicServerKey, this.publicTokenKey, this.privateTokenKey, diff --git a/lib/model/tokens/push_token.g.dart b/lib/model/tokens/push_token.g.dart index 8562146e7..577141cea 100644 --- a/lib/model/tokens/push_token.g.dart +++ b/lib/model/tokens/push_token.g.dart @@ -8,14 +8,14 @@ part of 'push_token.dart'; PushToken _$PushTokenFromJson(Map json) => PushToken( serial: json['serial'] as String, - expirationDate: json['expirationDate'] == null - ? null - : DateTime.parse(json['expirationDate'] as String), label: json['label'] as String, issuer: json['issuer'] as String, id: json['id'] as String, - enrollmentCredentials: json['enrollmentCredentials'] as String?, url: json['url'] == null ? null : Uri.parse(json['url'] as String), + expirationDate: json['expirationDate'] == null + ? null + : DateTime.parse(json['expirationDate'] as String), + enrollmentCredentials: json['enrollmentCredentials'] as String?, publicServerKey: json['publicServerKey'] as String?, publicTokenKey: json['publicTokenKey'] as String?, privateTokenKey: json['privateTokenKey'] as String?, diff --git a/lib/model/tokens/token.dart b/lib/model/tokens/token.dart index a6da653a7..50c6bba50 100644 --- a/lib/model/tokens/token.dart +++ b/lib/model/tokens/token.dart @@ -67,6 +67,14 @@ abstract class Token with SortableMixin { int? Function()? folderId, }); + @override + bool operator ==(Object other) { + return other is Token && other.id == id; + } + + @override + int get hashCode => (id + type).hashCode; + @override String toString() { return 'Token{label: $label, issuer: $issuer, id: $id, _sLocked: $isLocked, pin: $pin, tokenImage: $tokenImage, sortIndex: $sortIndex, type: $type, folderId: $folderId'; diff --git a/lib/model/tokens/totp_token.dart b/lib/model/tokens/totp_token.dart index 09abdbcff..d672ca472 100644 --- a/lib/model/tokens/totp_token.dart +++ b/lib/model/tokens/totp_token.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -// ignore: library_prefixes -import 'package:otp/otp.dart' as OTPLibrary; +import 'package:otp/otp.dart' as otp_library; import 'package:uuid/uuid.dart'; +import '../../utils/crypto_utils.dart'; import '../../utils/identifiers.dart'; import '../../utils/utils.dart'; import 'otp_token.dart'; @@ -17,7 +17,7 @@ class TOTPToken extends OTPToken { final int period; @override - String get otpValue => OTPLibrary.OTP.generateTOTPCodeString( + String get otpValue => otp_library.OTP.generateTOTPCodeString( secret, DateTime.now().millisecondsSinceEpoch, length: digits, diff --git a/lib/repo/preference_settings_repository.dart b/lib/repo/preference_settings_repository.dart index 365991e99..f2ddd53cf 100644 --- a/lib/repo/preference_settings_repository.dart +++ b/lib/repo/preference_settings_repository.dart @@ -1,7 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; +import '../interfaces/repo/settings_repository.dart'; import '../model/states/settings_state.dart'; -import 'settings_repository.dart'; class PreferenceSettingsRepository extends SettingsRepository { static const String _isFirstRunKey = 'KEY_IS_FIRST_RUN'; diff --git a/lib/repo/preference_token_folder_repository.dart b/lib/repo/preference_token_folder_repository.dart index 85ff7d812..880993af4 100644 --- a/lib/repo/preference_token_folder_repository.dart +++ b/lib/repo/preference_token_folder_repository.dart @@ -2,14 +2,14 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; +import '../interfaces/repo/token_folder_repository.dart'; import '../model/token_folder.dart'; import '../utils/logger.dart'; -import 'token_folder_repository.dart'; -class PreferenceTokenFolderRepotisory extends TokenFolderRepositoy { +class PreferenceTokenFolderRepository extends TokenFolderRepository { static const String _tokenFoldersKey = 'TOKEN_CATEGORIES'; final Future _prefs; - PreferenceTokenFolderRepotisory() : _prefs = SharedPreferences.getInstance(); + PreferenceTokenFolderRepository() : _prefs = SharedPreferences.getInstance(); @override Future> loadFolders() async { @@ -20,21 +20,21 @@ class PreferenceTokenFolderRepotisory extends TokenFolderRepositoy { final folders = jsons.map((e) => TokenFolder.fromJson(e)).toList(); return folders; } catch (e, s) { - Logger.error('Failed to load folders', name: 'PreferenceTokenFolderRepotisory#loadFolders', error: e, stackTrace: s); + Logger.error('Failed to load folders', name: 'PreferenceTokenFolderRepository#loadFolders', error: e, stackTrace: s); return []; } } @override - Future saveFolders(List folders) async { + Future> saveOrReplaceFolders(List folders) async { try { final jsons = folders.map((e) => e.toJson()).toList(); final json = jsonEncode(jsons); await _prefs.then((prefs) => prefs.setString(_tokenFoldersKey, json)); - return true; + return []; } catch (e, s) { - Logger.error('Failed to save folders', name: 'PreferenceTokenFolderRepotisory#saveFolders', error: e, stackTrace: s); - return false; + Logger.error('Failed to save folders', name: 'PreferenceTokenFolderRepository#saveFolders', error: e, stackTrace: s); + return folders; } } } diff --git a/lib/repo/secure_token_repository.dart b/lib/repo/secure_token_repository.dart new file mode 100644 index 000000000..c404ae207 --- /dev/null +++ b/lib/repo/secure_token_repository.dart @@ -0,0 +1,278 @@ +// ignore_for_file: constant_identifier_names + +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mutex/mutex.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/token_repository.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; +import 'package:privacyidea_authenticator/model/tokens/token.dart'; +import 'package:privacyidea_authenticator/utils/logger.dart'; +import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import 'package:privacyidea_authenticator/utils/view_utils.dart'; + +import '../views/settings_view/settings_view_widgets/send_error_dialog.dart'; +import '../widgets/default_dialog.dart'; +import '../widgets/default_dialog_button.dart'; + +// TODO How to test the behavior of this class? +class SecureTokenRepository implements TokenRepository { + const SecureTokenRepository(); + + // Use this to lock critical sections of code. + static final Mutex _m = Mutex(); + + /// Function [f] is executed, protected by Mutex [_m]. + /// That means, that calls of this method will always be executed serial. + static protect(Function f) => _m.protect(f as Future Function()); + + static const FlutterSecureStorage _storage = FlutterSecureStorage(); + + static const String _GLOBAL_PREFIX = 'app_v3_'; + + // ########################################################################### + // TOKENS + // ########################################################################### + + /// Saves [token]s securely on the device, if [token] already exists + /// in the storage the existing value is overwritten. + /// Returns all tokens that could not be saved. + @override + Future> saveOrReplaceTokens(List tokens) async { + final failedTokens = []; + for (var element in tokens) { + if (!await _saveOrReplaceToken(element)) { + failedTokens.add(element); + } + } + if (failedTokens.isNotEmpty) { + Logger.warning('Could not save all tokens to secure storage', name: 'storage_utils.dart#saveOrReplaceTokens', stackTrace: StackTrace.current); + } else { + Logger.info('Saved all (${tokens.length}) tokens to secure storage'); + } + return failedTokens; + } + + Future _saveOrReplaceToken(Token token) async { + try { + await _storage.write(key: _GLOBAL_PREFIX + token.id, value: jsonEncode(token)); + } catch (_) { + return false; + } + return true; + } + + /// Returns a list of all tokens that are saved in the secure storage of + /// this device. + /// If [loadLegacy] is set to true, will attempt to load old android and ios tokens. + @override + Future> loadTokens() async { + late Map keyValueMap; + try { + keyValueMap = await _storage.readAll(); + } on PlatformException catch (e, s) { + Logger.warning("Token found, but could not be decrypted.", name: 'storage_utils.dart#loadTokens', error: e, stackTrace: s, verbose: true); + _decryptErrorDialog(); + return []; + } + + List tokenList = []; + + for (var i = 0; i < keyValueMap.length; i++) { + final value = keyValueMap.values.elementAt(i); + final key = keyValueMap.keys.elementAt(i); + Map? serializedToken; + + try { + serializedToken = jsonDecode(value); + } on FormatException catch (e, s) { + if (key == _CURRENT_APP_TOKEN_KEY || key == _NEW_APP_TOKEN_KEY) { + continue; + } + Logger.warning( + 'Could not deserialize token from secure storage. Value: $value, key: $key', + name: 'storage_utils.dart#loadAllTokens', + error: e, + stackTrace: s, + verbose: true, + ); + // Skip everything that does not fit a serialized token + continue; + } + + if (serializedToken == null || !serializedToken.containsKey('type')) { + Logger.warning( + 'Could not deserialize token from secure storage. Value: $value\nserializedToken = $serializedToken\ncontainsKey(type) = ${serializedToken?.containsKey('type')} ', + name: 'storage_utils.dart#loadAllTokens'); + // Skip everything that fits for deserialization but is not a token + continue; + } + + // TODO token.version might be deprecated, is there a reason to use it? + // TODO when the token version (token.version) changed handle this here. + + // TODO Is this still needed? Can a json annotation be used instead to + // define default values? + // Handle new fields here + serializedToken['issuer'] ??= ''; + serializedToken['label'] ??= ''; + + tokenList.add(Token.fromJson(serializedToken)); + } + + Logger.info('Loaded ${tokenList.length} tokens from secure storage'); + return tokenList; + } + + @override + Future> deleteTokens(List tokens) async { + final failedTokens = []; + for (var element in tokens) { + if (!await _deleteToken(element)) { + failedTokens.add(element); + } + } + if (failedTokens.isNotEmpty) { + Logger.warning('Could not delete all tokens from secure storage', + name: 'storage_utils.dart#deleteTokens', error: 'Failed tokens: $failedTokens', stackTrace: StackTrace.current); + } + return failedTokens; + } + + /// Deletes the saved json of [token] from the secure storage. + Future _deleteToken(Token token) async { + try { + _storage.delete(key: _GLOBAL_PREFIX + token.id); + } catch (e, s) { + Logger.warning('Could not delete token from secure storage', name: 'storage_utils.dart#deleteToken', error: e, stackTrace: s); + return false; + } + Logger.info('Token deleted from secure storage'); + return true; + } + + // ########################################################################### + // FIREBASE CONFIG + // ########################################################################### + + static const _CURRENT_APP_TOKEN_KEY = '${_GLOBAL_PREFIX}CURRENT_APP_TOKEN'; + + static Future setCurrentFirebaseToken(String str) async => _storage.write(key: _CURRENT_APP_TOKEN_KEY, value: str); + + static Future getCurrentFirebaseToken() async => _storage.read(key: _CURRENT_APP_TOKEN_KEY); + + static const _NEW_APP_TOKEN_KEY = '${_GLOBAL_PREFIX}NEW_APP_TOKEN'; + + // This is used for checking if the token was updated. + static Future setNewFirebaseToken(String str) async => _storage.write(key: _NEW_APP_TOKEN_KEY, value: str); + + static Future getNewFirebaseToken() async => _storage.read(key: _NEW_APP_TOKEN_KEY); +} + +Future _decryptErrorDialog() => showAsyncDialog( + barrierDismissible: false, + builder: (context) => DefaultDialog( + title: Text(AppLocalizations.of(context)!.decryptErrorTitle), + content: Text(AppLocalizations.of(context)!.decryptErrorContent), + actions: [ + DefaultDialogButton( + onPressed: () async { + final isDataDeleted = await _decryptErrorDeleteTokenConfirmationDialog(); + if (isDataDeleted == true) { + // ignore: use_build_context_synchronously + Navigator.pop(context); + globalRef?.read(tokenProvider.notifier).loadFromRepo(); + } + }, + child: Text( + AppLocalizations.of(context)!.decryptErrorButtonDelete, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + DefaultDialogButton( + child: Text(AppLocalizations.of(context)!.decryptErrorButtonSendError), + onPressed: () async { + Logger.info('Sending error report', name: 'storage_utils.dart#_decryptErrorDialog'); + await showDialog( + context: context, + builder: (context) => const SendErrorDialog(), + useRootNavigator: false, + ); + }), + DefaultDialogButton( + onPressed: () async { + showDialog( + barrierDismissible: false, + context: context, + builder: (context) => const Center( + child: SizedBox( + height: 50, + width: 50, + child: CircularProgressIndicator(), + ), + ), + ); + await Future.delayed( + const Duration(milliseconds: 500), + ); + // ignore: use_build_context_synchronously + Navigator.pop(context); + // ignore: use_build_context_synchronously + Navigator.pop(context); + globalRef?.read(tokenProvider.notifier).loadFromRepo(); + }, + child: Text(AppLocalizations.of(context)!.decryptErrorButtonRetry), + ), + ], + ), + ); + +Future _decryptErrorDeleteTokenConfirmationDialog() => showAsyncDialog( + builder: (context) => DefaultDialog( + title: Text(AppLocalizations.of(context)!.decryptErrorTitle), + content: Text(AppLocalizations.of(context)!.decryptErrorDeleteConfirmationContent), + actions: [ + DefaultDialogButton( + onPressed: () => Navigator.pop(context, false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + DefaultDialogButton( + onPressed: () async { + Logger.info( + 'Deleting all tokens from secure storage', + name: 'storage_utils.dart#_decryptErrorDeleteTokenConfirmationDialog', + verbose: true, + ); + Navigator.pop(context, true); + await SecureTokenRepository._storage.deleteAll(); + }, + child: Text( + AppLocalizations.of(context)!.decryptErrorButtonDelete, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ), + ); diff --git a/lib/repo/token_folder_repository.dart b/lib/repo/token_folder_repository.dart deleted file mode 100644 index e605ba492..000000000 --- a/lib/repo/token_folder_repository.dart +++ /dev/null @@ -1,6 +0,0 @@ -import '../model/token_folder.dart'; - -abstract class TokenFolderRepositoy { - Future saveFolders(List folders); - Future> loadFolders(); -} diff --git a/lib/state_notifiers/deeplink_notifier.dart b/lib/state_notifiers/deeplink_notifier.dart index 53a1dc700..9df36d658 100644 --- a/lib/state_notifiers/deeplink_notifier.dart +++ b/lib/state_notifiers/deeplink_notifier.dart @@ -3,25 +3,28 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; import 'package:uni_links/uni_links.dart'; -StreamSubscription? _sub; +import '../utils/logger.dart'; + bool _initialUriIsHandled = false; class DeeplinkNotifier extends StateNotifier { + StreamSubscription? _sub; DeeplinkNotifier() : super(null) { _handleInitialUri(); _handleIncomingLinks(); } + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + /// Handle incoming links - the ones that the app will recieve from the OS /// while already started. void _handleIncomingLinks() { - if (_sub != null) { - _sub?.cancel(); - _sub = null; - } if (!kIsWeb) { // It will handle app links while the app is already started - be it in // the foreground or in the background. diff --git a/lib/state_notifiers/push_request_notifier.dart b/lib/state_notifiers/push_request_notifier.dart index da2334e41..1f9daf300 100644 --- a/lib/state_notifiers/push_request_notifier.dart +++ b/lib/state_notifiers/push_request_notifier.dart @@ -20,175 +20,77 @@ import 'dart:async'; -import 'package:collection/collection.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; import 'package:privacyidea_authenticator/model/push_request.dart'; import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; -import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart'; import 'package:privacyidea_authenticator/utils/logger.dart'; import 'package:privacyidea_authenticator/utils/network_utils.dart'; import 'package:privacyidea_authenticator/utils/push_provider.dart'; -import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; -import 'package:privacyidea_authenticator/utils/storage_utils.dart'; - -import '../utils/customizations.dart'; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart'; +/// Interface between the [PushProvider] and the UI. class PushRequestNotifier extends StateNotifier { // Used for periodically polling for push challenges - static Timer? _pollTimer; - final bool pollingEnabled; - - PushRequestNotifier(super.state, {required this.pollingEnabled}) { - _initStateAsync(); - } - - // INITIALIZATIONS - - /// Handles asynchronous calls that should be triggered by `initState`. - void _initStateAsync() async { - await PushProvider.initialize( - handleIncomingMessage: (RemoteMessage message) => _handleIncomingAuthRequest(message), - backgroundMessageHandler: _firebaseMessagingBackgroundHandler, - ); - Logger.info('PushProvider initialized. Polling for Challenges', name: 'main_screen.dart#_initStateAsync'); - PushProvider.pollForChallenges(); - _startOrStopPolling(); - } - - // FOREGROUND HANDLING - Future _handleIncomingAuthRequest(RemoteMessage remoteMessage) async { - Logger.info('Foreground message received.', name: 'main_screen.dart#_handleIncomingAuthRequest', error: remoteMessage.data); - await StorageUtil.protect(() async { - try { - return _handleIncomingRequest(remoteMessage); - } catch (e, s) { - final errorMessage = AppLocalizations.of(globalNavigatorKey.currentContext!)!.incomingAuthRequestError; - Logger.error(errorMessage, name: 'main_screen.dart#_handleIncomingAuthRequest', error: remoteMessage.data, stackTrace: s); - } - }); - } - // BACKGROUND HANDLING - static Future _firebaseMessagingBackgroundHandler(RemoteMessage remoteMessage) async { - Logger.info('Background message received.', name: 'main_screen.dart#_firebaseMessagingBackgroundHandler', error: remoteMessage.data); - await StorageUtil.protect(() async { - try { - return _handleIncomingRequest(remoteMessage, inBackground: true); - } catch (e, s) { - final errorMessage = AppLocalizations.of(globalNavigatorKey.currentContext!)!.incomingAuthRequestError; - Logger.error(errorMessage, name: 'main_screen.dart#_firebaseMessagingBackgroundHandler', error: remoteMessage.data, stackTrace: s); - } - }); - } - - // HANDLING - /// Handles incoming push requests by verifying the challenge and adding it - /// to the token. This should be guarded by a lock. - static Future _handleIncomingRequest(RemoteMessage message, {bool inBackground = false}) async { - var data = message.data; - Logger.info('Incoming push challenge.', name: 'main_screen.dart#_handleIncomingChallenge', error: data); - Uri requestUri = Uri.parse(data['url']); - - Logger.info('message: $data', name: 'main_screen.dart#_handleIncomingRequest'); - - bool sslVerify = (int.tryParse(data['sslverify']) ?? 0) == 1; - PushRequest pushRequest = PushRequest( - title: data['title'], - question: data['question'], - uri: requestUri, - nonce: data['nonce'], - sslVerify: sslVerify, - id: data['nonce'].hashCode, - // FIXME This is not guaranteed to not lead to collisions, but they might be unlikely in this case. - expirationDate: DateTime.now().add( - const Duration(seconds: 120), // Push requests expire after 2 minutes. - ), - serial: data['serial'], - signature: data['signature'], - ); - - Logger.info('Incoming push challenge for token with serial.', name: 'main_screen.dart#_handleIncomingChallenge', error: pushRequest.serial); - if (inBackground) { - _addPushRequestToTokenInSecureStoreage(pushRequest); - return; - } - globalRef?.read(pushRequestProvider.notifier).state = pushRequest; - } - - static void _addPushRequestToTokenInSecureStoreage(PushRequest pushRequest) async { - Logger.info('Adding push request to token in secure storage.', name: 'main_screen.dart#_addPushRequestToTokenInSecureStoreage', error: pushRequest); - var tokens = await StorageUtil.loadAllTokens(); - PushToken? token = tokens.firstWhereOrNull((token) => token is PushToken && token.serial == pushRequest.serial) as PushToken?; - if (token == null) { - Logger.warning('Token not found.', name: 'main_screen.dart#_addPushRequestToTokenInSecureStoreage', error: 'Serial: ${pushRequest.serial}'); - return; - } - final prList = token.pushRequests; - prList.add(pushRequest); - token = token.copyWith(pushRequests: prList); - await StorageUtil.saveOrReplaceToken(token); - } - - void _startOrStopPolling() { - // Start polling if enabled and not already polling - if (pollingEnabled && _pollTimer == null) { - Logger.info('Polling is enabled.', name: 'main_screen.dart#_startPollingIfEnabled'); - _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) => PushProvider.pollForChallenges()); - PushProvider.pollForChallenges(); - return; - } - // Stop polling if it's disabled and currently polling - if (!pollingEnabled && _pollTimer != null) { - Logger.info('Polling is disabled.', name: 'main_screen.dart#_startPollingIfEnabled'); - _pollTimer?.cancel(); - _pollTimer = null; - return; - } - // Do nothing if polling is enabled and already polling or disabled and not polling - return; + final PushProvider _pushProvider; + final PrivacyIdeaIOClient _ioClient; + final RsaUtils _rsaUtils; + + PushRequestNotifier({ + PushRequest? initState, + PushProvider? pushProvider, + PrivacyIdeaIOClient? ioClient, + RsaUtils? rsaUtils, + FirebaseUtils? firebaseUtils, + }) : _ioClient = ioClient ?? const PrivacyIdeaIOClient(), + _pushProvider = pushProvider ?? PushProvider(), + _rsaUtils = rsaUtils ?? const RsaUtils(), + super(initState) { + _pushProvider.initialize(pushSubscriber: this, firebaseUtils: firebaseUtils ?? FirebaseUtils()); } // ACTIONS - void accept(PushRequest pushRequest) async { - Logger.info('Approving push request.', name: 'main_screen.dart#approve', error: pushRequest); - pushRequest = pushRequest.copyWith(accepted: true); - final successfullyApproved = await handleReaction(pushRequest); - if (successfullyApproved) { - globalRef?.read(pushRequestProvider.notifier).state = pushRequest; + Future acceptPop(PushToken pushToken) async { + final pushRequest = pushToken.pushRequests.pop(); + Logger.info('Approving push request.', name: 'push_request_notifier.dart#approve'); + final updatedPushRequest = pushRequest.copyWith(accepted: true); + final successfullyApproved = await _handleReaction(pushRequest: updatedPushRequest, token: pushToken); + if (!successfullyApproved) { + pushToken.pushRequests.add(pushRequest); + return false; } + state = updatedPushRequest; + return true; } - void decline(PushRequest pushRequest) async { - Logger.info('Denying push request.', name: 'main_screen.dart#deny', error: pushRequest); - pushRequest = pushRequest.copyWith(accepted: false); - final successfullyDenied = await handleReaction(pushRequest); - if (successfullyDenied) { - globalRef?.read(pushRequestProvider.notifier).state = pushRequest; + Future declinePop(PushToken pushToken) async { + final pushRequest = pushToken.pushRequests.pop(); + Logger.info('Decline push request.', name: 'push_request_notifier.dart#decline'); + final updatedPushRequest = pushRequest.copyWith(accepted: false); + final successfullyDeclined = await _handleReaction(pushRequest: updatedPushRequest, token: pushToken); + if (!successfullyDeclined) { + pushToken.pushRequests.add(pushRequest); + return false; } + state = updatedPushRequest; + return successfullyDeclined; } - Future handleReaction(PushRequest pushRequest) async { - if (pushRequest.accepted == null) return false; - - final token = globalRef?.read(tokenProvider).tokens.firstWhereOrNull((token) => token is PushToken && token.serial == pushRequest.serial) as PushToken?; + void newRequest(PushRequest pushRequest) => state = pushRequest; - if (token == null) { - Logger.warning('Token not found.', name: 'token_widgets.dart#handleReaction', error: 'Serial: ${pushRequest.serial}'); - return false; - } + Future _handleReaction({required PushRequest pushRequest, required PushToken token}) async { + if (pushRequest.accepted == null) return false; - Logger.info('Push auth request accepted=${pushRequest.accepted}, sending response to privacyidea', - name: 'token_widgets.dart#handleReaction', error: 'Url: ${pushRequest.uri}'); + Logger.info('Push auth request accepted=${pushRequest.accepted}, sending response to privacyidea', name: 'token_widgets.dart#handleReaction'); // signature ::= {nonce}|{serial}[|decline] String msg = '${pushRequest.nonce}|${token.serial}'; if (pushRequest.accepted! == false) { msg += '|decline'; } - String? signature = await trySignWithToken(token, msg); + String? signature = await _rsaUtils.trySignWithToken(token, msg); if (signature == null) { return false; } @@ -207,13 +109,9 @@ class PushRequestNotifier extends StateNotifier { body["decline"] = "1"; } - Response response = await doPost(sslVerify: pushRequest.sslVerify, url: pushRequest.uri, body: body); + Response response = await _ioClient.doPost(sslVerify: pushRequest.sslVerify, url: pushRequest.uri, body: body); if (response.statusCode != 200) { - Logger.warning( - 'Sending push request response failed.', - name: 'token_widgets.dart#handleReaction', - error: 'Token: $token, Status code: ${response.statusCode}, Body: ${response.body}', - ); + Logger.warning('Sending push request response failed.', name: 'token_widgets.dart#handleReaction'); return false; } diff --git a/lib/state_notifiers/settings_notifier.dart b/lib/state_notifiers/settings_notifier.dart index 2d679ffe0..1dbfa4315 100644 --- a/lib/state_notifiers/settings_notifier.dart +++ b/lib/state_notifiers/settings_notifier.dart @@ -1,28 +1,31 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; +import '../interfaces/repo/settings_repository.dart'; import '../model/states/settings_state.dart'; -import '../repo/settings_repository.dart'; +import '../utils/logger.dart'; /// This class provies access to the device specific settings. /// It also ensures that the settings are saved to the device. /// To Update a state use: ref.read(settingsProvider.notifier).anyMethod(value) class SettingsNotifier extends StateNotifier { + late Future isLoading; final SettingsRepository _repo; - SettingsNotifier({required SettingsRepository repository, required SettingsState initialState}) - : _repo = repository, - super(initialState) { - _loadFromRepo(); - addListener((state) { - if (state != initialState) _saveToRepo(); + + SettingsNotifier({ + required SettingsRepository repository, + SettingsState? initialState, + }) : _repo = repository, + super(initialState ?? SettingsState()) { + loadFromRepo(); + } + void loadFromRepo() async { + isLoading = Future(() async { + state = await _repo.loadSettings(); + Logger.info('Loading settings from repo: $state', name: 'settings_notifier.dart#_loadFromRepo'); }); } - void _loadFromRepo() async { - state = await _repo.loadSettings(); - Logger.info('Loading settings from repo: $state', name: 'settings_notifier.dart#_loadFromRepo'); - } void _saveToRepo() async { Logger.info('Saving settings to repo: $state', name: 'settings_notifier.dart#_saveToRepo'); @@ -33,66 +36,96 @@ class SettingsNotifier extends StateNotifier { Logger.info('Crash report recipient added: $email', name: 'settings_notifier.dart#addCrashReportRecipient'); var updatedSet = state.crashReportRecipients..add(email); state = state.copyWith(crashReportRecipients: updatedSet); + _saveToRepo(); } set isFirstRun(bool value) { Logger.info('First run set to $value', name: 'settings_notifier.dart#setFirstRun'); state = state.copyWith(isFirstRun: value); + _saveToRepo(); } set hideOTPs(bool value) { Logger.info('Hide OTPs set to $value', name: 'settings_notifier.dart#setHideOTPs'); state = state.copyWith(hideOpts: value); + _saveToRepo(); } set showGuideOnStart(bool value) { Logger.info('Show guide on start set to $value', name: 'settings_notifier.dart#setShowGuideOnStart'); state = state.copyWith(showGuideOnStart: value); + _saveToRepo(); } void setLocalePreference(Locale locale) { Logger.info('Locale set to $locale', name: 'settings_notifier.dart#setLocalePreference'); state = state.copyWith(localePreference: locale); + _saveToRepo(); } void setUseSystemLocale(bool value) { Logger.info('Use system locale set to $value', name: 'settings_notifier.dart#setUseSystemLocale'); state = state.copyWith(useSystemLocale: value); + _saveToRepo(); } void enablePolling() { Logger.info('Polling set to true', name: 'settings_notifier.dart#enablePolling'); state = state.copyWith(enablePolling: true); + _saveToRepo(); } void disablePolling() { Logger.info('Polling set to false', name: 'settings_notifier.dart#disablePolling'); state = state.copyWith(enablePolling: false); + _saveToRepo(); } void setPolling(bool value) { Logger.info('Polling set to $value', name: 'settings_notifier.dart#setPolling'); state = state.copyWith(enablePolling: value); + _saveToRepo(); } void setLocale(Locale locale) { Logger.info('Locale set to $locale', name: 'settings_notifier.dart#setLocale'); state = state.copyWith(localePreference: locale); + _saveToRepo(); } void setVerboseLogging(bool value) { Logger.info('Verbose logging set to $value', name: 'settings_notifier.dart#setVerboseLogging'); state = state.copyWith(verboseLogging: value); + _saveToRepo(); } void toggleVerboseLogging() { final value = !state.verboseLogging; Logger.info('Verbose logging set to $value', name: 'settings_notifier.dart#setVerboseLogging'); state = state.copyWith(verboseLogging: value); + _saveToRepo(); } void setFirstRun(bool value) { Logger.info('First run set to $value', name: 'settings_notifier.dart#setFirstRun'); state = state.copyWith(isFirstRun: value); + _saveToRepo(); + } + + void setHidePushTokens({bool? isHidden, HidePushTokens? hidePushTokensState}) { + assert(isHidden != null || hidePushTokensState != null); + assert(isHidden == null || hidePushTokensState == null); + Logger.info('Hide push tokens set to $isHidden', name: 'settings_notifier.dart#setHidePushTokens'); + if (isHidden != null) { + if (isHidden) { + state = state.copyWith(hidePushTokensState: HidePushTokens.isHiddenNotNoticed); + } else { + state = state.copyWith(hidePushTokensState: HidePushTokens.notHidden); + } + } + if (hidePushTokensState != null) { + state = state.copyWith(hidePushTokensState: hidePushTokensState); + } + _saveToRepo(); } } diff --git a/lib/state_notifiers/token_folder_notifier.dart b/lib/state_notifiers/token_folder_notifier.dart index 905f5670c..c3433c1a1 100644 --- a/lib/state_notifiers/token_folder_notifier.dart +++ b/lib/state_notifiers/token_folder_notifier.dart @@ -1,39 +1,51 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../interfaces/repo/token_folder_repository.dart'; import '../model/states/token_folder_state.dart'; import '../model/token_folder.dart'; -import '../repo/token_folder_repository.dart'; class TokenFolderNotifier extends StateNotifier { - final TokenFolderRepositoy _repo; + Future? isLoading; + final TokenFolderRepository _repo; - TokenFolderNotifier({required TokenFolderRepositoy repositoy}) - : _repo = repositoy, - super(const TokenFolderState(folders: [])) { - _loadFromStorage(); + TokenFolderNotifier({required TokenFolderRepository repository, TokenFolderState? initialState}) + : _repo = repository, + super(initialState ?? const TokenFolderState(folders: [])) { + _loadFromRepo(); } - void _loadFromStorage() async => _repo.loadFolders().then((value) => state = TokenFolderState(folders: value)); + void _loadFromRepo() => isLoading = Future(() async => state = TokenFolderState(folders: await _repo.loadFolders())); - void _saveToStorage() async => _repo.saveFolders(state.folders); + void _saveOrReplaceFolders(List folders) { + isLoading = Future(() async { + final failedFolders = await _repo.saveOrReplaceFolders(folders); + if (failedFolders.isNotEmpty) { + state = state.withoutFolders(failedFolders); + } + }); + } void addFolder(String name) { - state = state.withFolder(name); - _saveToStorage(); + final newState = state.withFolder(name); + state = newState; + _saveOrReplaceFolders(newState.folders); } void removeFolder(TokenFolder folder) { - state = state.withoutFolder(folder); - _saveToStorage(); + final newState = state.withoutFolder(folder); + state = newState; + _saveOrReplaceFolders(newState.folders); } void updateFolder(TokenFolder folder) { - state = state.withUpdated(folders: [folder]); - _saveToStorage(); + final newState = state.withUpdated([folder]); + state = newState; + _saveOrReplaceFolders(newState.folders); } void updateFolders(List folders) { - state = state.withUpdated(folders: folders); - _saveToStorage(); + final newState = state.withUpdated(folders); + state = newState; + _saveOrReplaceFolders(newState.folders); } } diff --git a/lib/state_notifiers/token_notifier.dart b/lib/state_notifiers/token_notifier.dart index 67c89b170..b9194309f 100644 --- a/lib/state_notifiers/token_notifier.dart +++ b/lib/state_notifiers/token_notifier.dart @@ -5,61 +5,115 @@ import 'dart:io'; import 'package:base32/base32.dart'; import 'package:collection/collection.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; import 'package:pi_authenticator_legacy/pi_authenticator_legacy.dart'; import 'package:pointycastle/asymmetric/api.dart'; -import 'package:privacyidea_authenticator/model/enums/schemes.dart'; -import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; +import '../interfaces/repo/token_repository.dart'; +import '../l10n/app_localizations.dart'; +import '../model/enums/schemes.dart'; import '../model/push_request.dart'; import '../model/states/token_state.dart'; import '../model/tokens/hotp_token.dart'; import '../model/tokens/push_token.dart'; import '../model/tokens/token.dart'; -import '../utils/crypto_utils.dart'; +import '../repo/secure_token_repository.dart'; import '../utils/customizations.dart'; +import '../utils/firebase_utils.dart'; import '../utils/identifiers.dart'; import '../utils/logger.dart'; import '../utils/network_utils.dart'; -import '../utils/parsing_utils.dart'; -import '../utils/push_provider.dart'; -import '../utils/storage_utils.dart'; +import '../utils/qr_parser.dart'; +import '../utils/riverpod_providers.dart'; +import '../utils/rsa_utils.dart'; import '../utils/utils.dart'; import '../utils/view_utils.dart'; import '../widgets/two_step_dialog.dart'; class TokenNotifier extends StateNotifier { - TokenNotifier({TokenState? initialState}) - : super( + Future? isLoading; + final TokenRepository _repo; + final QrParser _qrParser; + final RsaUtils _rsaUtils; + final LegacyUtils _legacy; + final PrivacyIdeaIOClient _ioClient; + final FirebaseUtils _firebaseUtils; + + TokenNotifier({ + TokenState? initialState, + TokenRepository? repository, + QrParser? qrParser, + RsaUtils? rsaUtils, + LegacyUtils? legacy, + PrivacyIdeaIOClient? ioClient, + FirebaseUtils? firebaseUtils, + }) : _rsaUtils = rsaUtils ?? const RsaUtils(), + _qrParser = qrParser ?? const QrParser(), + _repo = repository ?? const SecureTokenRepository(), + _legacy = legacy ?? const LegacyUtils(), + _ioClient = ioClient ?? const PrivacyIdeaIOClient(), + _firebaseUtils = firebaseUtils ?? FirebaseUtils(), + super( initialState ?? TokenState(), ) { - _loadTokenList(); + loadFromRepo(); } - Future _loadTokenList() async { - List tokens = await StorageUtil.loadAllTokens(); - Logger.info('Loaded tokens from storage: $tokens', name: 'token_notifier.dart#_loadTokenList'); - final pushTokens = tokens.whereType(); - if (pushTokens.isNotEmpty) { - checkNotificationPermission(); - } + void _saveOrReplaceTokensRepo(List tokens) async { + isLoading = Future(() async { + final failedTokens = await _repo.saveOrReplaceTokens(tokens); + if (failedTokens.isNotEmpty) { + Logger.warning( + 'Saving tokens failed. Failed Tokens: ${failedTokens.length}', + name: 'token_notifier.dart#_saveOrReplaceTokens', + ); + state = state.addOrReplaceTokens(failedTokens); + } + }); + } - final pushTokensNotRolledOut = pushTokens.where((element) => !element.isRolledOut).toList(); - state = TokenState(tokens: tokens); - for (final pushToken in pushTokensNotRolledOut) { - rolloutPushToken(pushToken); + void _deleteTokensRepo(List tokens) async { + isLoading = Future(() async { + final failedTokens = await _repo.deleteTokens(tokens); + state = state.addOrReplaceTokens(failedTokens); + if (state.hasPushTokens == false) { + globalRef?.read(settingsProvider.notifier).setHidePushTokens(isHidden: false); + } + }); + } + + Future loadFromRepo() async { + List tokens; + try { + isLoading = Future(() async { + tokens = await _repo.loadTokens(); + state = TokenState(tokens: tokens); + if (state.pushTokens.firstWhereOrNull((element) => element.isRolledOut == true) != null) { + checkNotificationPermission(); + } + }); + } catch (_) { + return false; } + return true; } - void refreshTokens() async { - List tokens = await StorageUtil.loadAllTokens(); + Future refreshRolledOutPushTokens() async { + await isLoading; + List tokens; + try { + tokens = await _repo.loadTokens(); + } catch (_) { + return false; + } final rolledOutPushToken = tokens.whereType().where((element) => element.isRolledOut).toList(); - Logger.info('Refreshed Pushtokens from storage: $tokens', name: 'token_notifier.dart#refreshTokens'); - state = state.updateTokens(rolledOutPushToken); + Logger.info('Refreshed ${rolledOutPushToken.length} Pushtokens from storage.', name: 'token_notifier.dart#refreshTokens'); + state = state.addOrReplaceTokens(rolledOutPushToken); + return true; } Token? getTokenFromId(String id) { @@ -67,105 +121,88 @@ class TokenNotifier extends StateNotifier { } void incrementCounter(HOTPToken token) { - token = token.copyWith(counter: token.counter + 1); - state = state.updateToken(token); - StorageUtil.saveOrReplaceToken(token); - } - - void addToken(Token token) { - state = state.withToken(token); - StorageUtil.saveOrReplaceToken(token); - } - - void addTokens(List tokens) { - state = state.withTokens(tokens); - for (final token in tokens) { - StorageUtil.saveOrReplaceToken(token); - } + token = state.currentOf(token)?.copyWith(counter: token.counter + 1) ?? token.copyWith(counter: token.counter + 1); + state = state.replaceToken(token); + _saveOrReplaceTokensRepo([token]); } void removeToken(Token token) { state = state.withoutToken(token); - StorageUtil.deleteToken(token); + _deleteTokensRepo([token]); } - void removeTokens(List tokens) { - state = state.withoutTokens(tokens); - for (final token in tokens) { - StorageUtil.deleteToken(token); - } + void addOrReplaceToken(Token token) { + state = state.addOrReplaceToken(token); + _saveOrReplaceTokensRepo([token]); } - PushToken removePushTokenBySerial(String serial) { - final token = state.tokens.firstWhere((element) => element is PushToken && element.serial == serial); - state = state.withoutToken(token); - return token as PushToken; + void addOrReplaceTokens(List updatedTokens) { + state = state.addOrReplaceTokens(updatedTokens); + _saveOrReplaceTokensRepo(updatedTokens); } - Token removeTokenById(String id) { - final token = state.tokens.firstWhere((element) => element.id == id); - state = state.withoutToken(token); - return token; - } - - List removeTokensByIds(List ids) { - final tempTokens = List.from(state.tokens); - final tokensToRemove = tempTokens.where((element) => ids.contains(element.id)).toList(); - removeTokens(tokensToRemove); - return tokensToRemove; - } - - void updateToken(Token token) { - state = state.updateToken(token); - StorageUtil.saveOrReplaceToken(token); + void updateToken(T token, T Function(T) updater) { + final current = state.currentOf(token); + if (current == null) { + Logger.warning('Tried to update a token that does not exist.', name: 'token_notifier.dart#updateToken'); + return; + } + final updated = updater(current); + state = state.replaceToken(updated); + _saveOrReplaceTokensRepo([updated]); } - void updateTokens(List updatedTokens) { - state = state.updateTokens(updatedTokens); - for (Token token in updatedTokens) { - StorageUtil.saveOrReplaceToken(token); + void updateTokens(List token, T Function(T) updater) { + List updatedTokens = []; + for (final t in token) { + final current = state.currentOf(t) ?? t; + updatedTokens.add(updater(current)); } + state = state.replaceTokens(updatedTokens); + _saveOrReplaceTokensRepo(updatedTokens); } void handleLink(Uri uri) { if (uri.scheme == enumAsString(UriSchemes.otpauth)) { - addTokenFromOtpAuth(otpAuth: uri.toString(), context: globalNavigatorKey.currentContext!); + addTokenFromOtpAuth(otpAuth: uri.toString()); return; } if (uri.scheme == enumAsString(UriSchemes.pia)) { - addTokenFromPia(pia: uri.toString(), context: globalNavigatorKey.currentContext!); + addTokenFromPia(pia: uri.toString()); return; } showMessage(message: 'Scheme "${uri.scheme}" is not supported', duration: const Duration(seconds: 3)); } - void addTokenFromPia({required String pia, required BuildContext context}) async { + void addTokenFromPia({required String pia}) async { // TODO: Implement pia:// scheme showMessage(message: 'Scheme "pia" is not implemented yet', duration: const Duration(seconds: 3)); } - void addTokenFromOtpAuth({required String otpAuth, required BuildContext context}) async { - Logger.info( - 'Try to handle otpAuth:', - name: 'token_notifier.dart#addTokenFromOtpAuth', - error: otpAuth, - ); + void addTokenFromOtpAuth({ + required String otpAuth, + }) async { + Logger.info('Try to handle otpAuth:', name: 'token_notifier.dart#addTokenFromOtpAuth'); try { - Map uriMap = parseQRCodeToMap(otpAuth); - - if (is2StepURI(Uri.parse(otpAuth))) { + Map uriMap = _qrParser.parseQRCodeToMap(otpAuth); + if (_qrParser.is2StepURI(Uri.parse(otpAuth))) { + final secret = uriMap[URI_SECRET] as Uint8List; // Calculate the whole secret. - uriMap[URI_SECRET] = (await showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => TwoStepDialog( - iterations: uriMap[URI_ITERATIONS], - keyLength: uriMap[URI_OUTPUT_LENGTH_IN_BYTES], - saltLength: uriMap[URI_SALT_LENGTH], - password: uriMap[URI_SECRET], - ), - ))!; + Uint8List? twoStepSecret; + while (twoStepSecret == null) { + twoStepSecret = (await showAsyncDialog( + barrierDismissible: false, + builder: (BuildContext context) => GenerateTwoStepDialog( + iterations: uriMap[URI_ITERATIONS], + keyLength: uriMap[URI_OUTPUT_LENGTH_IN_BYTES], + saltLength: uriMap[URI_SALT_LENGTH], + password: secret, + ), + )); + await Future.delayed(const Duration(milliseconds: 500)); + } + uriMap[URI_SECRET] = twoStepSecret; } Token newToken; try { @@ -180,202 +217,225 @@ class TokenNotifier extends StateNotifier { showMessage(message: 'A token with the serial ${newToken.serial} already exists!', duration: const Duration(seconds: 2)); return; } - addToken(newToken); + addOrReplaceToken(newToken); if (newToken is PushToken) { rolloutPushToken(newToken); } + + return; } on ArgumentError catch (e, s) { // Error while parsing qr code. - Logger.warning('Malformed QR code:', name: 'main_screen.dart#_handleOtpAuth', error: e, stackTrace: s); - + Logger.warning('Malformed QR code:', name: 'token_notifier.dart#_handleOtpAuth', error: e, stackTrace: s); showMessage(message: '${e.message}\n Please inform the creator of this qr code about the problem.', duration: const Duration(seconds: 8)); + return; } } - Future addPushRequestToToken(PushRequest pr) async { + Future addPushRequestToToken(PushRequest pr) async { + await isLoading; PushToken? token = state.tokens.whereType().firstWhereOrNull((t) => t.serial == pr.serial && t.isRolledOut); - Logger.info('Adding push request ${pr.id} to token ${token?.id}', name: 'main_screen.dart#_handleIncomingChallenge', error: pr.serial); + Logger.info('Adding push request to token', name: 'token_notifier.dart#addPushRequestToToken'); if (token == null) { - Logger.warning('The requested token does not exist or is not rolled out.', name: 'main_screen.dart#_handleIncomingChallenge', error: pr.serial); - } else { - String signature = pr.signature; - String signedData = '${pr.nonce}|' - '${pr.uri}|' - '${pr.serial}|' - '${pr.question}|' - '${pr.title}|' - '${pr.sslVerify ? '1' : '0'}'; - - // Re-add url and sslverify to android legacy tokens: - if (token.url == null) { - token = token.copyWith(url: pr.uri, sslVerify: pr.sslVerify); - } + Logger.warning('The requested token does not exist or is not rolled out.', name: 'token_notifier.dart#addPushRequestToToken'); + return false; + } + String signature = pr.signature; + String signedData = '${pr.nonce}|' + '${pr.uri}|' + '${pr.serial}|' + '${pr.question}|' + '${pr.title}|' + '${pr.sslVerify ? '1' : '0'}'; + + // Re-add url and sslverify to android legacy tokens: + if (token.url == null) { + token = token.copyWith(url: pr.uri, sslVerify: pr.sslVerify); + } + bool isVerified = token.privateTokenKey == null + ? await _legacy.verify(token.serial, signedData, signature) + : _rsaUtils.verifyRSASignature(token.rsaPublicServerKey!, utf8.encode(signedData) as Uint8List, base32.decode(signature)); + + if (!isVerified) { + Logger.warning( + 'Validating incoming message failed.', + name: 'token_notifier.dart#addPushRequestToToken', + error: 'Signature does not match signed data.', + ); + return false; + } + Logger.info('Validating incoming message was successful.', name: 'token_notifier.dart#addPushRequestToToken'); - bool isVerified = token.privateTokenKey == null - ? await Legacy.verify(token.serial, signedData, signature) - : verifyRSASignature(token.rsaPublicServerKey!, utf8.encode(signedData) as Uint8List, base32.decode(signature)); + if (token.knowsRequestWithId(pr.id)) { + Logger.info( + 'The push request already exists.', + name: 'token_notifier.dart#addPushRequestToToken', + ); + return false; + } + // Save the pending request. + updateToken(token, (p0) => p0.withPushRequest(pr)); - if (!isVerified) { - Logger.warning( - 'Validating incoming message failed.', - name: 'main_screen.dart#_handleIncomingChallenge', - error: 'Signature $signature does not match signed data: $signedData', - ); - return; - } - Logger.info('Validating incoming message was successful.', name: 'main_screen.dart#_handleIncomingChallenge'); + // Remove the request after it expires. + int time = pr.expirationDate.difference(DateTime.now()).inMilliseconds; + Future.delayed(Duration(milliseconds: time < 1 ? 1 : time), () async => globalRef?.read(tokenProvider.notifier).removePushRequest(pr)); - if (token.knowsRequestWithId(pr.id)) { - Logger.info( - 'The push request ${pr.id} already exists ' - 'for the token with serial ${token.serial}', - name: 'main_screen.dart#_handleIncomingChallenge', - ); - return; - } - // Save the pending request. - token = token.withPushRequest(pr); - updateToken(token); - Logger.info('Added push request ${pr.id} to token ${token.id}', name: 'main_screen.dart#_handleIncomingChallenge'); - } + Logger.info('Added push request ${pr.id} to token ${token.id}', name: 'token_notifier.dart#addPushRequestToToken'); + return true; } - void removePushRequest(PushRequest pushRequest) { + bool removePushRequest(PushRequest pushRequest) { Logger.info('Removing push request ${pushRequest.id}'); PushToken? token = state.tokens.whereType().firstWhereOrNull((t) => t.serial == pushRequest.serial); if (token == null) { - Logger.warning('The requested token does not exist.', name: 'main_screen.dart#_handleIncomingChallenge', error: pushRequest.serial); - return; + Logger.warning('The requested token with serial "${pushRequest.serial}" does not exist.', name: 'token_notifier.dart#removePushRequest'); + return false; } - token = token.withoutPushRequest(pushRequest); - updateToken(token); - Logger.info('Removed push request ${pushRequest.id} from token ${token.id}', name: 'main_screen.dart#_handleIncomingChallenge'); + updateToken(token, (p0) => p0.withoutPushRequest(pushRequest)); + + Logger.info('Removed push request from token ${token.id}', name: 'token_notifier.dart#removePushRequest'); + return true; } Future rolloutPushToken(PushToken token) async { - token = getTokenFromId(token.id) as PushToken? ?? token; - Logger.info('Rolling out token ${token.serial}', name: 'token_widgets.dart#rolloutPushToken'); + token = (getTokenFromId(token.id)) as PushToken? ?? token; + assert(token.url != null, 'Token url is null. Cannot rollout token without url.'); + Logger.info('Rolling out token "${token.id}"', name: 'token_notifier.dart#rolloutPushToken'); if (token.isRolledOut) return true; if (token.rolloutState != PushTokenRollOutState.rolloutNotStarted && token.rolloutState != PushTokenRollOutState.generatingRSAKeyPairFailed && token.rolloutState != PushTokenRollOutState.sendRSAPublicKeyFailed && token.rolloutState != PushTokenRollOutState.parsingResponseFailed) { - Logger.info('Ignoring rollout request: Rollout of token ${token.serial} already started. Tokenstate: ${token.rolloutState} ', - name: 'token_widgets.dart#rolloutPushToken'); + Logger.info('Ignoring rollout request: Rollout of token "${token.id}" already started. Tokenstate: ${token.rolloutState} ', + name: 'token_notifier.dart#rolloutPushToken'); return false; } if (token.expirationDate?.isBefore(DateTime.now()) == true) { - Logger.info('Ignoring rollout request: Token ${token.serial} is expired. ', name: 'token_widgets.dart#rolloutPushToken'); - showMessage( - message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutTokenExpired(token.label), duration: const Duration(seconds: 3)); - globalRef!.read(tokenProvider.notifier).removeToken(token); + Logger.info('Ignoring rollout request: Token "${token.id}" is expired. ', name: 'token_notifier.dart#rolloutPushToken'); + if (globalNavigatorKey.currentContext != null) { + showMessage( + message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutTokenExpired(token.label), + duration: const Duration(seconds: 3), + ); + } + removeToken(token); return false; } - if (Platform.isIOS) { - await dummyRequest(url: token.url!, sslVerify: token.sslVerify); + if (!kIsWeb && Platform.isIOS) { + await _ioClient.triggerNetworkAccessPermission(url: token.url!, sslVerify: token.sslVerify); } if (token.privateTokenKey == null) { - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.generatingRSAKeyPair)); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.generatingRSAKeyPair)); try { - final keyPair = await generateRSAKeyPair(); + final keyPair = await _rsaUtils.generateRSAKeyPair(); token = token.withPrivateTokenKey(keyPair.privateKey); token = token.withPublicTokenKey(keyPair.publicKey); - updateToken(token); - Logger.info('Updated token.${token.id}', name: 'token_widgets.dart#rolloutPushToken', error: keyPair.publicKey); - checkNotificationPermission(); + updateToken(token, (p0) { + p0 = p0.withPrivateTokenKey(keyPair.privateKey); + return p0.withPublicTokenKey(keyPair.publicKey); + }); + Logger.info('Updated token "${token.id}"', name: 'token_notifier.dart#rolloutPushToken'); } catch (e, s) { - Logger.warning('Error while generating RSA key pair.', name: 'token_widgets.dart#rolloutPushToken', error: e, stackTrace: s); - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.generatingRSAKeyPairFailed)); + Logger.error('Error while generating RSA key pair.', name: 'token_notifier.dart#rolloutPushToken', error: e, stackTrace: s); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.generatingRSAKeyPairFailed)); return false; } } - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKey)); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKey)); try { // TODO What to do with poll only tokens if google-services is used? - Response response = await doPost(sslVerify: token.sslVerify, url: token.url!, body: { - 'enrollment_credential': token.enrollmentCredentials, - 'serial': token.serial, - 'fbtoken': await PushProvider.getFBToken(), - 'pubkey': serializeRSAPublicKeyPKCS8(token.rsaPublicTokenKey!), - }); + Response response = await _ioClient.doPost( + sslVerify: token.sslVerify, + url: token.url!, + body: { + 'enrollment_credential': token.enrollmentCredentials, + 'serial': token.serial, + 'fbtoken': await _firebaseUtils.getFBToken(), + 'pubkey': _rsaUtils.serializeRSAPublicKeyPKCS8(token.rsaPublicTokenKey!), + }, + ); if (response.statusCode == 200) { - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.parsingResponse)); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.parsingResponse)); try { RSAPublicKey publicServerKey = await _parseRollOutResponse(response); token = token.withPublicServerKey(publicServerKey); } on FormatException catch (e, s) { showMessage(message: "Couldn't parsing RSA public key: ${e.message}", duration: const Duration(seconds: 3)); - Logger.warning('Error while parsing RSA public key.', name: 'token_widgets.dart#rolloutPushToken', error: e, stackTrace: s); - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.parsingResponseFailed)); + Logger.warning('Error while parsing RSA public key.', name: 'token_notifier.dart#rolloutPushToken', error: e, stackTrace: s); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.parsingResponseFailed)); return false; - } finally { - Logger.info('Roll out successful', name: 'token_widgets.dart#rolloutPushToken', error: token); - token = token.copyWith(isRolledOut: true, rolloutState: PushTokenRollOutState.rolloutComplete); - updateToken(token); } + Logger.info('Roll out successful', name: 'token_notifier.dart#rolloutPushToken'); + updateToken(token, (p0) => p0.copyWith(isRolledOut: true, rolloutState: PushTokenRollOutState.rolloutComplete)); + checkNotificationPermission(); return true; } else { Logger.warning('Post request on roll out failed.', - name: 'token_widgets.dart#rolloutPushToken', + name: 'token_notifier.dart#rolloutPushToken', error: 'Token: ${token.serial}\nStatus code: ${response.statusCode},\nURL:${response.request?.url}\nBody: ${response.body}'); String? message; try { message = response.body.isNotEmpty ? (json.decode(response.body)['result']?['error']?['message']) : null; + message = message != null ? '\n$message' : ''; + showMessage( + message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutFailed(token.label, response.statusCode) + message, + duration: const Duration(seconds: 3), + ); } on FormatException catch (_) { - message = AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutNoConnectionToServer(token.label); + // Format Exception is thrown if the response body is not a valid json. This happens if the server is not reachable. + showMessage( + message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutNoConnectionToServer(token.label), + duration: const Duration(seconds: 3), + ); } - message = message != null ? '\n$message' : ''; - showMessage( - message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutFailed(token.label, response.statusCode) + message, - duration: const Duration(seconds: 3), - ); - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); + + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); return false; } } catch (e, s) { if (e is PlatformException && e.code == FIREBASE_TOKEN_ERROR_CODE || e is SocketException || e is TimeoutException || e is FirebaseException) { - Logger.warning('Connection error: Roll out push token [${token.serial}] failed.', name: 'token_widgets.dart#rolloutPushToken', error: e, stackTrace: s); + Logger.warning('Connection error: Roll out push token failed.', name: 'token_notifier.dart#rolloutPushToken', error: e, stackTrace: s); showMessage( message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutNoConnectionToServer(token.label), duration: const Duration(seconds: 3), ); - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); } else if (e is HandshakeException) { - Logger.warning('SSL error: Roll out push token [${token.serial}] failed.', name: 'token_widgets.dart#rolloutPushToken', error: e, stackTrace: s); + Logger.warning('SSL error: Roll out push token failed.', name: 'token_notifier.dart#rolloutPushToken', error: e, stackTrace: s); showMessage( message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutSSLHandshakeFailed, duration: const Duration(seconds: 3), ); - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); } else { - showMessage( - message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutUnknownError(e), - duration: const Duration(seconds: 3), - ); - Logger.error('Roll out push token [${token.serial}] failed.', name: 'token_widgets.dart#rolloutPushToken', error: e, stackTrace: s); - updateToken(token.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); + if (globalNavigatorKey.currentContext != null) { + showMessage( + message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorRollOutUnknownError(e), + duration: const Duration(seconds: 3), + ); + } + Logger.error('Roll out push token failed.', name: 'token_notifier.dart#rolloutPushToken', error: e, stackTrace: s); + updateToken(token, (p0) => p0.copyWith(rolloutState: PushTokenRollOutState.sendRSAPublicKeyFailed)); } return false; } } -} - -Future _parseRollOutResponse(Response response) async { - Logger.info('Parsing rollout response, try to extract public_key.', name: 'token_widgets.dart#_parseRollOutResponse', error: response.body); - try { - String key = json.decode(response.body)['detail']['public_key']; - key = key.replaceAll('\n', ''); + Future _parseRollOutResponse(Response response) async { + Logger.info('Parsing rollout response, try to extract public_key.', name: 'token_notifier.dart#_parseRollOutResponse'); + try { + String key = json.decode(response.body)['detail']['public_key']; + key = key.replaceAll('\n', ''); - Logger.info('Extracting public key was successful.', name: 'token_widgets.dart#_parseRollOutResponse', error: key); + Logger.info('Extracting public key was successful.', name: 'token_notifier.dart#_parseRollOutResponse', error: key); - return deserializeRSAPublicKeyPKCS1(key); - } on FormatException catch (e) { - throw FormatException('Response body does not contain RSA public key.', e); + return _rsaUtils.deserializeRSAPublicKeyPKCS1(key); + } on FormatException catch (e) { + throw FormatException('Response body does not contain RSA public key.', e); + } } } diff --git a/lib/utils/app_customizer.dart b/lib/utils/app_customizer.dart index 8aff3d351..b3c8d52a0 100644 --- a/lib/utils/app_customizer.dart +++ b/lib/utils/app_customizer.dart @@ -1,6 +1,217 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:flutter/material.dart'; -class ApplicationCustomizer { +class ThemeCustomization { + static const ThemeCustomization defaultLightTheme = ThemeCustomization.defaultLightWith(); + static const ThemeCustomization defaultDarkTheme = ThemeCustomization.defaultDarkWith(); + + const ThemeCustomization({ + required this.primaryColor, + required this.onPrimary, + required this.subtitleColor, + required this.backgroundColor, + required this.foregroundColor, + required this.shadowColor, + required this.deleteColor, + required this.renameColor, + required this.lockColor, + required this.actionButtonsForegroundColor, + required this.tilePrimaryColor, + required this.tileIconColor, + required this.tileSubtitleColor, + required this.navigationBarColor, + required this.navigationBarIconColor, + required this.qrButtonBackgroundColor, + required this.qrButtonIconColor, + }); + + const ThemeCustomization.defaultLightWith({ + Color? primaryColor, + Color? onPrimary, + Color? subtitleColor, + Color? backgroundColor, + Color? foregroundColor, + Color? shadowColor, + Color? deleteColor, + Color? renameColor, + Color? lockColor, + this.actionButtonsForegroundColor, + this.tilePrimaryColor, + Color? tileIconColor, + this.tileSubtitleColor, + Color? navigationBarColor, + this.navigationBarIconColor, + this.qrButtonBackgroundColor, + this.qrButtonIconColor, + }) : primaryColor = primaryColor ?? Colors.lightBlue, + onPrimary = onPrimary ?? const Color(0xFF282828), + subtitleColor = subtitleColor ?? const Color(0xFF9E9E9E), + navigationBarColor = navigationBarColor ?? Colors.white, + backgroundColor = backgroundColor ?? const Color(0xFFEFEFEF), + foregroundColor = foregroundColor ?? const Color(0xff282828), + shadowColor = shadowColor ?? const Color(0xFF303030), + deleteColor = deleteColor ?? const Color(0xffE04D2D), + renameColor = renameColor ?? const Color(0xff6A8FE5), + lockColor = lockColor ?? const Color(0xffFFD633), + tileIconColor = tileIconColor ?? const Color(0xff9E9E9E); + + const ThemeCustomization.defaultDarkWith({ + Color? primaryColor, + Color? onPrimary, + Color? subtitleColor, + Color? backgroundColor, + Color? foregroundColor, + Color? shadowColor, + Color? deleteColor, + Color? renameColor, + Color? lockColor, + this.actionButtonsForegroundColor, + this.tilePrimaryColor, + Color? tileIconColor, + this.tileSubtitleColor, + Color? navigationBarColor, + this.navigationBarIconColor, + this.qrButtonBackgroundColor, + this.qrButtonIconColor, + }) : primaryColor = primaryColor ?? Colors.lightBlue, + onPrimary = onPrimary ?? const Color(0xFF282828), + subtitleColor = subtitleColor ?? const Color(0xFF9E9E9E), + backgroundColor = backgroundColor ?? const Color(0xFF303030), + foregroundColor = foregroundColor ?? const Color(0xffF5F5F5), + shadowColor = shadowColor ?? const Color(0xFFEFEFEF), + deleteColor = deleteColor ?? const Color(0xffCD3C14), + renameColor = renameColor ?? const Color(0xff527EDB), + lockColor = lockColor ?? const Color(0xffFFCC00), + tileIconColor = tileIconColor ?? const Color(0xffF5F5F5), + navigationBarColor = navigationBarColor ?? const Color(0xFF282828); + + // Basic colors + final Color primaryColor; + final Color onPrimary; + final Color subtitleColor; + final Color backgroundColor; + final Color foregroundColor; + final Color shadowColor; + + // Slide action + final Color deleteColor; + final Color renameColor; + final Color lockColor; + final Color? actionButtonsForegroundColor; // Default: foregroundColor + + // List tile + final Color? tilePrimaryColor; // Default: primaryColor + final Color tileIconColor; + final Color? tileSubtitleColor; // Default: subtitleColor + + // Navigation bar + final Color navigationBarColor; + final Color? navigationBarIconColor; // Default: foregroundColor + final Color? qrButtonBackgroundColor; // Default: primaryColor + final Color? qrButtonIconColor; // Default: onPrimary + + ThemeCustomization copyWith({ + Color? primaryColor, + Color? onPrimary, + Color? subtitleColor, + Color? backgroundColor, + Color? foregroundColor, + Color? shadowColor, + Color? deleteColor, + Color? renameColor, + Color? lockColor, + Color? Function()? actionButtonsForegroundColor, + Color? Function()? tilePrimaryColor, + Color? tileIconColor, + Color? Function()? tileSubtitleColor, + Color? navigationBarColor, + Color? Function()? navigationBarIconColor, + Color? Function()? qrButtonBackgroundColor, + Color? Function()? qrButtonIconColor, + }) => + ThemeCustomization( + primaryColor: primaryColor ?? this.primaryColor, + onPrimary: onPrimary ?? this.onPrimary, + subtitleColor: subtitleColor ?? this.subtitleColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + shadowColor: shadowColor ?? this.shadowColor, + deleteColor: deleteColor ?? this.deleteColor, + renameColor: renameColor ?? this.renameColor, + lockColor: lockColor ?? this.lockColor, + actionButtonsForegroundColor: actionButtonsForegroundColor != null ? actionButtonsForegroundColor() : this.actionButtonsForegroundColor, + tilePrimaryColor: tilePrimaryColor != null ? tilePrimaryColor() : this.tilePrimaryColor, + tileIconColor: tileIconColor ?? this.tileIconColor, + tileSubtitleColor: tileSubtitleColor != null ? tileSubtitleColor() : this.tileSubtitleColor, + navigationBarColor: navigationBarColor ?? this.navigationBarColor, + navigationBarIconColor: navigationBarIconColor != null ? navigationBarIconColor() : this.navigationBarIconColor, + qrButtonBackgroundColor: qrButtonBackgroundColor != null ? qrButtonBackgroundColor() : this.qrButtonBackgroundColor, + qrButtonIconColor: qrButtonIconColor != null ? qrButtonIconColor() : this.qrButtonIconColor, + ); + + factory ThemeCustomization.fromJsonDark(Map json) => ThemeCustomization.defaultDarkWith( + primaryColor: json['primaryColor'] != null ? Color(json['primaryColor'] as int) : null, + onPrimary: json['onPrimary'] != null ? Color(json['onPrimary'] as int) : null, + subtitleColor: json['subtitleColor'] != null ? Color(json['subtitleColor'] as int) : null, + backgroundColor: json['backgroundColor'] != null ? Color(json['backgroundColor'] as int) : null, + foregroundColor: json['foregroundColor'] != null ? Color(json['foregroundColor'] as int) : null, + shadowColor: json['shadowColor'] != null ? Color(json['shadowColor'] as int) : null, + deleteColor: json['deleteColor'] != null ? Color(json['deleteColor'] as int) : null, + renameColor: json['renameColor'] != null ? Color(json['renameColor'] as int) : null, + lockColor: json['lockColor'] != null ? Color(json['lockColor'] as int) : null, + actionButtonsForegroundColor: json['actionButtonsForegroundColor'] != null ? Color(json['actionButtonsForegroundColor'] as int) : null, + tilePrimaryColor: json['tilePrimaryColor'] != null ? Color(json['tilePrimaryColor'] as int) : null, + tileIconColor: json['tileIconColor'] != null ? Color(json['tileIconColor'] as int) : null, + tileSubtitleColor: json['tileSubtitleColor'] != null ? Color(json['tileSubtitleColor'] as int) : null, + navigationBarColor: json['navigationBarColor'] != null ? Color(json['navigationBarColor'] as int) : null, + navigationBarIconColor: json['navigationBarIconColor'] != null ? Color(json['navigationBarIconColor'] as int) : null, + qrButtonBackgroundColor: json['qrButtonBackgroundColor'] != null ? Color(json['qrButtonBackgroundColor'] as int) : null, + qrButtonIconColor: json['qrButtonIconColor'] != null ? Color(json['qrButtonIconColor'] as int) : null, + ); + + factory ThemeCustomization.fromJsonLight(Map json) => ThemeCustomization.defaultLightWith( + primaryColor: json['primaryColor'] != null ? Color(json['primaryColor'] as int) : null, + onPrimary: json['onPrimary'] != null ? Color(json['onPrimary'] as int) : null, + subtitleColor: json['subtitleColor'] != null ? Color(json['subtitleColor'] as int) : null, + backgroundColor: json['backgroundColor'] != null ? Color(json['backgroundColor'] as int) : null, + foregroundColor: json['foregroundColor'] != null ? Color(json['foregroundColor'] as int) : null, + shadowColor: json['shadowColor'] != null ? Color(json['shadowColor'] as int) : null, + deleteColor: json['deleteColor'] != null ? Color(json['deleteColor'] as int) : null, + renameColor: json['renameColor'] != null ? Color(json['renameColor'] as int) : null, + lockColor: json['lockColor'] != null ? Color(json['lockColor'] as int) : null, + actionButtonsForegroundColor: json['actionButtonsForegroundColor'] != null ? Color(json['actionButtonsForegroundColor'] as int) : null, + tilePrimaryColor: json['tilePrimaryColor'] != null ? Color(json['tilePrimaryColor'] as int) : null, + tileIconColor: json['tileIconColor'] != null ? Color(json['tileIconColor'] as int) : null, + tileSubtitleColor: json['tileSubtitleColor'] != null ? Color(json['tileSubtitleColor'] as int) : null, + navigationBarColor: json['navigationBarColor'] != null ? Color(json['navigationBarColor'] as int) : null, + navigationBarIconColor: json['navigationBarIconColor'] != null ? Color(json['navigationBarIconColor'] as int) : null, + qrButtonBackgroundColor: json['qrButtonBackgroundColor'] != null ? Color(json['qrButtonBackgroundColor'] as int) : null, + qrButtonIconColor: json['qrButtonIconColor'] != null ? Color(json['qrButtonIconColor'] as int) : null, + ); + + Map toJson() => { + 'primaryColor': primaryColor.value, + 'onPrimary': onPrimary.value, + 'subtitleColor': subtitleColor.value, + 'backgroundColor': backgroundColor.value, + 'foregroundColor': foregroundColor.value, + 'shadowColor': shadowColor.value, + 'deleteColor': deleteColor.value, + 'renameColor': renameColor.value, + 'lockColor': lockColor.value, + 'actionButtonsForegroundColor': actionButtonsForegroundColor?.value, + 'tilePrimaryColor': tilePrimaryColor?.value, + 'tileIconColor': tileIconColor.value, + 'tileSubtitleColor': tileSubtitleColor?.value, + 'navigationBarColor': navigationBarColor.value, + 'navigationBarIconColor': navigationBarIconColor?.value, + 'qrButtonBackgroundColor': qrButtonBackgroundColor?.value, + }; +} + +class ApplicationCustomization { // Edit in android/app/src/main/AndroidManifest.xml file // @@ -30,32 +241,224 @@ class ApplicationCustomizer { // - /android/app/src/release // 2. iOS: in /ios/ add the GoogleService-Info.plist - static const String appName = "privacyIDEA Authenticator"; - static const String websiteLink = 'https://netknights.it/'; - static const String appIcon = 'res/logo/app_logo_light.png'; + final String appName; + final String websiteLink; + final Uint8List appIconUint8List; + final Uint8List appImageUint8List; + Image get appIcon => Image.memory(appIconUint8List); + Image get appImage => Image.memory(appImageUint8List); + final ThemeCustomization lightTheme; + final ThemeCustomization darkTheme; - static const Color primaryColor = Colors.lightBlue; - static const Color themeColorDark = Color(0xFF282828); - static const Color themeColorLight = Colors.white; + static final defaultCustomization = ApplicationCustomization( + appName: 'privacyIDEA Authenticator', + websiteLink: 'https://netknights.it/', + appIconUint8List: defaultIconUint8List, + appImageUint8List: defaultImageUint8List, + lightTheme: ThemeCustomization.defaultLightTheme, + darkTheme: ThemeCustomization.defaultDarkTheme, + ); - static const Color backgroundColorDark = Color(0xFF303030); - static const Color backgroundColorLight = Color(0xFFEFEFEF); + const ApplicationCustomization({ + required this.appName, + required this.websiteLink, + required this.appIconUint8List, + required this.appImageUint8List, + required this.lightTheme, + required this.darkTheme, + }); - // Slide action - static const Color deleteColorDark = Color(0xffCD3C14); - static const Color deleteColorLight = Color(0xffE04D2D); - static const Color renameColorDark = Color(0xff527EDB); - static const Color renameColorLight = Color(0xff6A8FE5); - static const Color lockColorDark = Color(0xffFFCC00); - static const Color lockColorLight = Color(0xffFFD633); + ApplicationCustomization copyWith({ + String? appName, + String? websiteLink, + Uint8List? appIconUint8List, + Uint8List? appImageUint8List, + ThemeCustomization? lightTheme, + ThemeCustomization? darkTheme, + Color? primaryColor, + }) => + ApplicationCustomization( + appName: appName ?? this.appName, + websiteLink: websiteLink ?? this.websiteLink, + appIconUint8List: appIconUint8List ?? this.appIconUint8List, + appImageUint8List: appImageUint8List ?? this.appImageUint8List, + lightTheme: lightTheme ?? this.lightTheme, + darkTheme: darkTheme ?? this.darkTheme, + ); - static const Color buttonColor = primaryColor; - static const Color textColor = primaryColor; - static const Color timerColor = primaryColor; + ThemeData generateLightTheme() => _generateTheme(lightTheme, Brightness.light); - // List tile - static const Color tileIconColorLight = Color(0xff9E9E9E); - static const Color tileSubtitleColorLight = Color(0xff757575); - static const Color tileIconColorDark = Color(0xffF5F5F5); - static const Color tileSubtitleColorDark = Color(0xff9E9E9E); + ThemeData generateDarkTheme() => _generateTheme(darkTheme, Brightness.dark); + + factory ApplicationCustomization.fromJson(Map json) => defaultCustomization.copyWith( + appName: json['appName'] as String, + websiteLink: json['websiteLink'] as String, + appIconUint8List: json['appIconBASE64'] != null ? base64Decode(json['appIconBASE64']! as String) : null, + appImageUint8List: json['appImageBASE64'] != null ? base64Decode(json['appImageBASE64'] as String) : null, + lightTheme: ThemeCustomization.fromJsonLight(json['lightTheme'] as Map), + darkTheme: ThemeCustomization.fromJsonDark(json['darkTheme'] as Map), + ); + + Map toJson() { + return { + 'appName': appName, + 'websiteLink': websiteLink, + 'appIconBASE64': base64Encode(appIconUint8List), + 'appImageBASE64': base64Encode(appImageUint8List), + 'lightTheme': lightTheme.toJson(), + 'darkTheme': darkTheme.toJson(), + }; + } } + +ThemeData _generateTheme(ThemeCustomization theme, Brightness brightness) { + return ThemeData( + brightness: brightness, + textTheme: const TextTheme().copyWith( + bodyLarge: TextStyle(color: theme.foregroundColor), + bodyMedium: TextStyle(color: theme.foregroundColor), + titleMedium: TextStyle(color: theme.foregroundColor), + titleSmall: TextStyle(color: theme.foregroundColor), + displayLarge: TextStyle(color: theme.foregroundColor), + displayMedium: TextStyle(color: theme.foregroundColor), + displaySmall: TextStyle(color: theme.foregroundColor), + headlineMedium: TextStyle(color: theme.foregroundColor), + headlineSmall: TextStyle(color: theme.foregroundColor), + titleLarge: TextStyle(color: theme.foregroundColor), + bodySmall: TextStyle(color: theme.tileSubtitleColor), + labelLarge: TextStyle(color: theme.foregroundColor), + labelSmall: TextStyle(color: theme.foregroundColor), + ), + scaffoldBackgroundColor: theme.backgroundColor, + cardColor: theme.backgroundColor, + appBarTheme: const AppBarTheme().copyWith( + backgroundColor: theme.backgroundColor, + shadowColor: theme.shadowColor, + foregroundColor: theme.foregroundColor, + elevation: 0, + ), + primaryIconTheme: IconThemeData(color: theme.foregroundColor), + navigationBarTheme: const NavigationBarThemeData().copyWith( + backgroundColor: theme.navigationBarColor, + shadowColor: theme.shadowColor, + iconTheme: MaterialStatePropertyAll(IconThemeData(color: theme.navigationBarIconColor ?? theme.foregroundColor)), + elevation: 3, + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: theme.qrButtonBackgroundColor ?? theme.primaryColor, + foregroundColor: theme.qrButtonIconColor ?? theme.onPrimary, + ), + listTileTheme: ListTileThemeData( + tileColor: theme.backgroundColor, + titleTextStyle: TextStyle(color: theme.tilePrimaryColor ?? theme.primaryColor), + subtitleTextStyle: TextStyle(color: theme.tileSubtitleColor), + iconColor: theme.tileIconColor, + ), + colorScheme: brightness == Brightness.light + ? ColorScheme.light( + primary: theme.primaryColor, + secondary: theme.primaryColor, + onPrimary: theme.onPrimary, + onSecondary: theme.onPrimary, + errorContainer: theme.deleteColor, + ) + : ColorScheme.dark( + primary: theme.primaryColor, + secondary: theme.primaryColor, + onPrimary: theme.onPrimary, + onSecondary: theme.onPrimary, + errorContainer: theme.deleteColor, + ), + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return theme.primaryColor; + } + return null; + }), + ), + radioTheme: RadioThemeData( + fillColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return theme.primaryColor; + } + return null; + }), + ), + switchTheme: SwitchThemeData( + thumbColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return theme.primaryColor; + } + return null; + }), + trackColor: MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return null; + } + if (states.contains(MaterialState.selected)) { + return theme.primaryColor; + } + return null; + }), + ), + extensions: [ + ActionTheme( + deleteColor: theme.deleteColor, + editColor: theme.renameColor, + lockColor: theme.lockColor, + foregroundColor: theme.actionButtonsForegroundColor ?? theme.foregroundColor, + ), + ]); +} + +class ActionTheme extends ThemeExtension { + final Color deleteColor; + final Color editColor; + final Color lockColor; + final Color foregroundColor; + const ActionTheme({ + required this.deleteColor, + required this.editColor, + required this.lockColor, + required this.foregroundColor, + }); + + @override + ThemeExtension lerp(covariant ActionTheme? other, double t) => ActionTheme( + deleteColor: Color.lerp(deleteColor, other?.deleteColor, t) ?? deleteColor, + editColor: Color.lerp(editColor, other?.editColor, t) ?? editColor, + lockColor: Color.lerp(lockColor, other?.lockColor, t) ?? lockColor, + foregroundColor: Color.lerp(foregroundColor, other?.foregroundColor, t) ?? foregroundColor, + ); + + @override + ThemeExtension copyWith({Color? deleteColor, Color? editColor, Color? lockColor, Color? foregroundColor}) => ActionTheme( + deleteColor: deleteColor ?? this.deleteColor, + editColor: editColor ?? this.editColor, + lockColor: lockColor ?? this.lockColor, + foregroundColor: foregroundColor ?? this.foregroundColor, + ); +} + +// /// Calculate HSP and check if the primary color is bright or dark +// /// brightness = sqrt( .299 R^2 + .587 G^2 + .114 B^2 ) +// /// c.f., http://alienryderflex.com/hsp.html +// bool _isColorBright(Color color) { +// return sqrt(0.299 * pow(color.red, 2) + 0.587 * pow(color.green, 2) + 0.114 * pow(color.blue, 2)) > 150; +// } +final Uint8List defaultIconUint8List = base64Decode( + "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", +); +final Uint8List defaultImageUint8List = base64Decode( + "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", +); diff --git a/lib/utils/crypto_utils.dart b/lib/utils/crypto_utils.dart index a245c186e..21a26fdfc 100644 --- a/lib/utils/crypto_utils.dart +++ b/lib/utils/crypto_utils.dart @@ -19,16 +19,16 @@ */ import 'dart:convert'; +import 'dart:core'; import 'dart:math' as math; +import 'package:base32/base32.dart' as base32_converter; import 'package:base32/base32.dart'; import 'package:flutter/foundation.dart'; -import 'package:pi_authenticator_legacy/pi_authenticator_legacy.dart'; +import 'package:hex/hex.dart' as hex_converter; +import 'package:otp/otp.dart' as otp_library; import 'package:pointycastle/export.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; -import 'package:privacyidea_authenticator/utils/utils.dart'; -import '../model/tokens/push_token.dart'; import 'identifiers.dart'; Future pbkdf2({required Uint8List salt, required int iterations, required int keyLength, required Uint8List password}) async { @@ -43,7 +43,7 @@ Future pbkdf2({required Uint8List salt, required int iterations, requ map['keyLength'] = keyLength; // Funky converting of password because that is what the server does too. - map['password'] = utf8.encode(encodeAsHex(password)); + map['password'] = utf8.encode(encodeSecretAs(password, Encodings.hex)); return compute(_pbkdfIsolate, map); } @@ -76,22 +76,6 @@ Future generatePhoneChecksum({required Uint8List phonePart}) async { return base32.encode(Uint8List.fromList(toEncode)).replaceAll('=', ''); } -Future> generateRSAKeyPair() async { - Logger.info('Start generating RSA key pair', name: 'crypto_utils.dart#generateRSAKeyPair'); - AsymmetricKeyPair keyPair = await compute(_generateRSAKeyPairIsolate, 4096); - Logger.info('Finished generating RSA key pair', name: 'crypto_utils.dart#generateRSAKeyPair'); - return keyPair; -} - -/// Computationally costly method to be run in an isolate. -AsymmetricKeyPair _generateRSAKeyPairIsolate(int bitLength) { - final keyGen = RSAKeyGenerator()..init(ParametersWithRandom(RSAKeyGeneratorParameters(BigInt.parse('65537'), bitLength, 64), secureRandom())); - - final pair = keyGen.generateKeyPair(); - - return AsymmetricKeyPair(pair.publicKey as RSAPublicKey, pair.privateKey as RSAPrivateKey); -} - /// Provides a secure random number generator. SecureRandom secureRandom() { final secureRandom = FortunaRandom(); @@ -106,56 +90,29 @@ SecureRandom secureRandom() { return secureRandom; } -/// signedMessage is what was allegedly signed, signature gets validated -bool verifyRSASignature(RSAPublicKey publicKey, Uint8List signedMessage, Uint8List signature) { - RSASigner signer = Signer(SIGNING_ALGORITHM) as RSASigner; // Get algorithm from registry - signer.init(false, PublicKeyParameter(publicKey)); // false to validate +Uint8List decodeSecretToUint8(String secret, Encodings encoding) => switch (encoding) { + Encodings.none => Uint8List.fromList(utf8.encode(secret)), + Encodings.hex => Uint8List.fromList(hex_converter.HEX.decode(secret)), + Encodings.base32 => base32_converter.base32.decode(secret), + }; - bool isVerified = false; +String encodeSecretAs(Uint8List secret, Encodings encoding) => switch (encoding) { + Encodings.none => utf8.decode(secret), + Encodings.hex => hex_converter.HEX.encode(secret), + Encodings.base32 => base32_converter.base32.encode(secret), + }; + +bool isValidEncoding(String secret, Encodings encoding) { try { - isVerified = signer.verifySignature(signedMessage, RSASignature(signature)); - } on ArgumentError catch (e, s) { - Logger.warning('Verifying signature failed due to ${e.name}', name: 'crypto_utils.dart#verifyRSASignature', error: e, stackTrace: s); + decodeSecretToUint8(secret, encoding); + } catch (_) { + return false; } - - return isVerified; + return true; } -String createBase32Signature(RSAPrivateKey privateKey, Uint8List dataToSign) { - return base32.encode(createRSASignature(privateKey, dataToSign)); -} - -Uint8List createRSASignature(RSAPrivateKey privateKey, Uint8List dataToSign) { - RSASigner signer = Signer(SIGNING_ALGORITHM) as RSASigner; // Get algorithm from registry - signer.init(true, PrivateKeyParameter(privateKey)); // true to sign - - return signer.generateSignature(dataToSign).bytes; -} - -/// Tries to sign the [message] with the private key of the [token]. If the token is a -/// legacy token (enrolled prior to v3), the Legacy plugin will be used for that operation. -/// If an error occurs during the operation of the legacy plugin, a dialog will be shown -/// if a [context] is provided, telling the users that it might be better to enroll a new -/// push token so that the app can directly access the private key. -/// Returns the signature on success and null on failure. -Future trySignWithToken(PushToken token, String message) async { - String? signature; - if (token.privateTokenKey == null) { - // It is a legacy token so the operation could cause an exception - try { - signature = await Legacy.sign(token.serial, message); - } catch (error, stackTrace) { - Logger.error("Error", - error: "An error occured while using the legacy token ${token.label}. " - "The token was enrolled in a old version of this app, which may cause trouble" - " using it. It is suggested to enroll a new push token if the problems persist!", - name: 'crypto_utils.dart#trySignWithToken', - stackTrace: stackTrace); - return null; - } - } else { - signature = createBase32Signature(token.rsaPrivateTokenKey!, utf8.encode(message) as Uint8List); - } - - return signature; -} +otp_library.Algorithm mapAlgorithms(Algorithms algorithm) => switch (algorithm) { + Algorithms.SHA1 => otp_library.Algorithm.SHA1, + Algorithms.SHA256 => otp_library.Algorithm.SHA256, + Algorithms.SHA512 => otp_library.Algorithm.SHA512, + }; diff --git a/lib/utils/customizations.dart b/lib/utils/customizations.dart index 9d2630833..265b8576a 100644 --- a/lib/utils/customizations.dart +++ b/lib/utils/customizations.dart @@ -22,8 +22,5 @@ import 'package:flutter/material.dart'; -const String applicationName = 'privacyIDEA Authenticator'; // Default: privacyIDEA Authenticator - -final GlobalKey globalSnackbarKey = GlobalKey(); - -final GlobalKey globalNavigatorKey = GlobalKey(); +final globalSnackbarKey = GlobalKey(); +final globalNavigatorKey = GlobalKey(); diff --git a/lib/utils/firebase_utils.dart b/lib/utils/firebase_utils.dart new file mode 100644 index 000000000..d423a0643 --- /dev/null +++ b/lib/utils/firebase_utils.dart @@ -0,0 +1,141 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../repo/secure_token_repository.dart'; +import 'customizations.dart'; +import 'identifiers.dart'; +import 'logger.dart'; + +class FirebaseUtils { + static FirebaseUtils? _instance; + bool _initialized = false; + + FirebaseUtils._(); + + factory FirebaseUtils() { + _instance ??= FirebaseUtils._(); + return _instance!; + } + + Future initFirebase({ + required Future Function(RemoteMessage) foregroundHandler, + required Future Function(RemoteMessage) backgroundHandler, + required dynamic Function(String?) updateFirebaseToken, + }) async { + if (_initialized) { + return; + } + _initialized = true; + await Firebase.initializeApp(); + + try { + // await FirebaseMessaging.instance.requestPermission(); + } on FirebaseException catch (e, s) { + Logger.warning( + 'e.code: ${e.code}, ' + 'e.message: ${e.message}, ' + 'e.plugin: ${e.plugin},', + name: 'push_provider.dart#_initFirebase', + error: e, + stackTrace: s, + ); + String errorMessage = e.message ?? 'no error message'; + final SnackBar snackBar = SnackBar( + content: Text( + "Firebase notification permission error! ($errorMessage: ${e.code}", + )); + globalSnackbarKey.currentState?.showSnackBar(snackBar); + } + + FirebaseMessaging.onMessage.listen(foregroundHandler); + FirebaseMessaging.onBackgroundMessage(backgroundHandler); + + try { + String? firebaseToken = await getFBToken(); + + if (firebaseToken != await SecureTokenRepository.getCurrentFirebaseToken() && firebaseToken != null) { + updateFirebaseToken(firebaseToken); + } + } on PlatformException catch (error) { + if (error.code == FIREBASE_TOKEN_ERROR_CODE) { + // ignore + } else { + String errorMessage = error.message ?? 'no error message'; + final SnackBar snackBar = SnackBar( + content: Text( + 'Push cant be initialized, restart the app and try again. ${error.code}: $errorMessage', + overflow: TextOverflow.fade, + softWrap: false, + )); + globalSnackbarKey.currentState?.showSnackBar(snackBar); + } + } on FirebaseException catch (error) { + final SnackBar snackBar = SnackBar( + content: Text( + "Push cant be initialized, restart the app and try again$error", + overflow: TextOverflow.fade, + softWrap: false, + )); + globalSnackbarKey.currentState?.showSnackBar(snackBar); + } catch (error) { + final SnackBar snackBar = SnackBar( + content: Text( + "Unknown error: $error", + overflow: TextOverflow.fade, + softWrap: false, + )); + globalSnackbarKey.currentState?.showSnackBar(snackBar); + } + + FirebaseMessaging.instance.onTokenRefresh.listen((String newToken) async { + if ((await SecureTokenRepository.getCurrentFirebaseToken()) != newToken) { + await SecureTokenRepository.setNewFirebaseToken(newToken); + // TODO what if this fails, when should a retry be attempted? + try { + updateFirebaseToken(newToken); + } catch (error) { + final SnackBar snackBar = SnackBar( + content: Text( + "Unknown error: $error", + overflow: TextOverflow.fade, + softWrap: false, + )); + globalSnackbarKey.currentState?.showSnackBar(snackBar); + } + } + }); + } + + /// Returns the current firebase token of the app / device. Throws a + /// PlatformException with a custom error code if retrieving the firebase + /// token failed. This may happen if, e.g., no network connection is available. + Future getFBToken() async { + String? firebaseToken; + try { + firebaseToken = await FirebaseMessaging.instance.getToken(); + } on FirebaseException catch (e, s) { + String errorMessage = e.message ?? 'no error message'; + Logger.warning('Unable to retrieve Firebase token! ($errorMessage: ${e.code})', name: 'push_provider.dart#getFBToken', error: e, stackTrace: s); + } + + // Fall back to the last known firebase token + if (firebaseToken == null) { + firebaseToken = await SecureTokenRepository.getCurrentFirebaseToken(); + } else { + await SecureTokenRepository.setNewFirebaseToken(firebaseToken); + } + + if (firebaseToken == null) { + // This error should be handled in all cases, the user might be informed + // in the form of a pop-up message. + throw PlatformException( + message: 'Firebase token could not be retrieved, the only know cause of this is' + ' that the firebase servers could not be reached.', + code: FIREBASE_TOKEN_ERROR_CODE); + } + + return firebaseToken; + } +} diff --git a/lib/utils/lock_auth.dart b/lib/utils/lock_auth.dart index c0d845572..100b5c7a0 100644 --- a/lib/utils/lock_auth.dart +++ b/lib/utils/lock_auth.dart @@ -1,16 +1,17 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:local_auth/local_auth.dart'; import 'package:local_auth_android/local_auth_android.dart'; import 'package:local_auth_ios/local_auth_ios.dart'; + +import '../l10n/app_localizations.dart'; import '../widgets/default_dialog.dart'; import 'customizations.dart'; -import 'view_utils.dart'; - import 'logger.dart'; +import 'view_utils.dart'; bool authenticationInProgress = false; @@ -18,7 +19,7 @@ Future lockAuth({required BuildContext context, required String localizedR bool didAuthenticate = false; LocalAuthentication localAuth = LocalAuthentication(); - if (!(await localAuth.isDeviceSupported())) { + if (kIsWeb || !(await localAuth.isDeviceSupported())) { await showAsyncDialog( builder: (context) { return DefaultDialog( @@ -72,7 +73,8 @@ Future lockAuth({required BuildContext context, required String localizedR authenticationInProgress = false; } } on PlatformException catch (e, s) { - Logger.error('Error: ${e.code}', name: 'token_widgets.dart#lockAuth', error: e, stackTrace: s); + authenticationInProgress = false; + Logger.info("Authentication failed", error: e, stackTrace: s); } return didAuthenticate; } diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index dcb4bcb0e..136031e8b 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -3,16 +3,17 @@ import 'dart:async'; import 'dart:io'; import 'dart:isolate'; +import 'dart:math'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_mailer/flutter_mailer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logger/logger.dart' as printer; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; import 'package:privacyidea_authenticator/utils/app_customizer.dart'; import '../views/settings_view/settings_view_widgets/send_error_dialog.dart'; @@ -25,7 +26,7 @@ class Logger { /*----------- STATIC FIELDS & GETTER -----------*/ static Logger? _instance; static BuildContext? get _context => navigatorKey.currentContext; - static String get _mailBody => _context != null ? AppLocalizations.of(_context!)!.errorLogFileAttached : 'Error Log File Attached'; + static String get _mailBody => _context != null ? AppLocalizations.of(_context!)!.errorMailBody : 'Error Log File Attached'; static printer.Logger print = printer.Logger( printer: printer.PrettyPrinter( methodCount: 0, @@ -70,7 +71,7 @@ class Logger { String get _mailRecipient => 'app-crash@netknights.it'; String get _mailSubject => _platformInfos != null ? '(${_platformInfos!.version}+${_platformInfos!.buildNumber}) ${_platformInfos!.appName} >>> $_lastError' - : '${ApplicationCustomizer.appName} >>> $_lastError'; + : '${ApplicationCustomization.defaultCustomization.appName} >>> $_lastError'; String get _filename => 'logfile.txt'; String? get _fullPath => _logPath != null ? '$_logPath/$_filename' : null; bool get _verbose { @@ -112,27 +113,31 @@ class Logger { /*----------- LOGGING METHODS -----------*/ - static void info(String message, {dynamic error, dynamic stackTrace, String? name}) { - final infoString = instance._convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.INFO); - if (instance._verbose) { + static void info(String message, {dynamic error, dynamic stackTrace, String? name, bool verbose = false}) { + String infoString = instance._convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.INFO); + infoString = _textFilter(infoString); + if (instance._verbose || verbose) { instance._logToFile(infoString); } _print(infoString); } - static void warning(String message, {dynamic error, dynamic stackTrace, String? name}) { - final warningString = instance._convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.WARNING); - if (instance._verbose) { + static void warning(String message, {dynamic error, dynamic stackTrace, String? name, bool verbose = false}) { + String warningString = instance._convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.WARNING); + warningString = _textFilter(warningString); + if (instance._verbose || verbose) { instance._logToFile(warningString); } _printWarning(warningString); } static void error(String? message, {required dynamic error, required dynamic stackTrace, String? name}) { - final errorString = instance._convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.ERROR); - instance._lastError = message ?? ''; - if (instance._lastError.isEmpty) { - instance._lastError = error.toString().substring(0, 100); + String errorString = instance._convertLogToSingleString(message, error: error, stackTrace: stackTrace, name: name, logLevel: LogLevel.ERROR); + errorString = _textFilter(errorString); + if (message != null) { + instance._lastError = message.substring(0, min(message.length, 100)); + } else if (error != null) { + instance._lastError = error.toString().substring(0, min(error.toString().length, 100)); } instance._logToFile(errorString); instance._showSnackbar(); @@ -159,7 +164,7 @@ class Logger { } Future _sendErrorLog() async { - if (_fullPath == null) return false; + if (_fullPath == null || kIsWeb) return false; final File file = File(_fullPath!); if (!file.existsSync() || file.lengthSync() == 0) { return false; @@ -175,9 +180,15 @@ class Logger { deviceInfo = _readIosDeviceInfo(data); } + final completeMailBody = """$_mailBody +--------------------------------------------------------- + +Device Parameters: +$deviceInfo"""; + try { final MailOptions mailOptions = MailOptions( - body: '$_mailBody\n\n\nDevice Parameters:$deviceInfo\n\nStacktrace:\n${file.readAsStringSync()}', + body: completeMailBody, subject: _mailSubject, recipients: [_mailRecipient], attachments: [ @@ -185,8 +196,8 @@ class Logger { ], ); await FlutterMailer.send(mailOptions); - } catch (exc, stackTrace) { - Logger.error('Was not able to send the Email', error: exc, stackTrace: stackTrace, name: 'Logger#_sendErrorLog()'); + } catch (e, stackTrace) { + Logger.error('Was not able to send the Email', error: e, stackTrace: stackTrace, name: 'Logger#_sendErrorLog()'); return false; } return true; @@ -325,15 +336,16 @@ class Logger { showDialog( context: _context!, builder: (context) => const SendErrorDialog(), + useRootNavigator: false, ); } /*----------- HELPER -----------*/ - String _textFilter(String text) { + static String _textFilter(String text) { for (var key in filterParameterKeys) { final regex = RegExp(r'(?<=' + key + r':\s).+?(?=[},])'); - text = text.replaceAll(regex, '***'); + text = text.replaceAll(regex, '******'); } return text; } diff --git a/lib/utils/network_utils.dart b/lib/utils/network_utils.dart index 1cd9a0012..b39bace4d 100644 --- a/lib/utils/network_utils.dart +++ b/lib/utils/network_utils.dart @@ -20,120 +20,125 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:http/io_client.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:privacyidea_authenticator/utils/logger.dart'; import 'package:privacyidea_authenticator/utils/view_utils.dart'; -/// Dummy network request can be used to trigger the network access permission -/// on iOS devices. Doing this at an appropriate place in the code can prevent -/// SocketExceptions. -Future dummyRequest({required Uri url, bool sslVerify = true}) async { - HttpClient httpClient = HttpClient(); - httpClient.badCertificateCallback = ((X509Certificate cert, String host, int port) => !sslVerify); - httpClient.userAgent = 'privacyIDEA-App /' - ' ${Platform.operatingSystem}' - ' ${(await PackageInfo.fromPlatform()).version}'; - - IOClient ioClient = IOClient(httpClient); - - try { - await ioClient.post(url, body: ''); - } on SocketException { - // ignore - } finally { - ioClient.close(); +class PrivacyIdeaIOClient { + const PrivacyIdeaIOClient(); + + /// Dummy network request can be used to trigger the network access permission + /// on iOS devices. Doing this at an appropriate place in the code can prevent + /// SocketExceptions. + Future triggerNetworkAccessPermission({required Uri url, bool sslVerify = true}) async { + if (kIsWeb) return; + HttpClient httpClient = HttpClient(); + httpClient.badCertificateCallback = ((X509Certificate cert, String host, int port) => !sslVerify); + httpClient.userAgent = 'privacyIDEA-App /' + ' ${Platform.operatingSystem}' + ' ${(await PackageInfo.fromPlatform()).version}'; + + IOClient ioClient = IOClient(httpClient); + + try { + await ioClient.post(url, body: ''); + } on SocketException { + // ignore + } finally { + ioClient.close(); + } } -} -/// Custom POST request allows to not verify certificates. -Future doPost({required Uri url, required Map body, bool sslVerify = true}) async { - Logger.info('Sending post request', name: 'utils.dart#doPost', error: 'URI: $url, SSLVerify: $sslVerify, Body: $body'); - - List entries = body.entries.where((element) => element.value == null).toList(); - if (entries.isNotEmpty) { - List nullEntries = []; - for (MapEntry entry in entries) { - nullEntries.add(entry.key); + /// Custom POST request allows to not verify certificates. + Future doPost({required Uri url, required Map body, bool sslVerify = true}) async { + if (kIsWeb) return Response('', 405); + Logger.info('Sending post request (SSLVerify: $sslVerify)', name: 'utils.dart#doPost'); + + List entries = body.entries.where((element) => element.value == null).toList(); + if (entries.isNotEmpty) { + List nullEntries = []; + for (MapEntry entry in entries) { + nullEntries.add(entry.key); + } + throw ArgumentError('Can not send request because the argument [body] contains a null values' + ' at entries $nullEntries, this is not permitted.'); } - throw ArgumentError('Can not send request because the argument [body] contains a null values' - ' at entries $nullEntries, this is not permitted.'); - } - HttpClient httpClient = HttpClient(); - httpClient.badCertificateCallback = ((X509Certificate cert, String host, int port) => !sslVerify); - httpClient.userAgent = 'privacyIDEA-App /' - ' ${Platform.operatingSystem}' - ' ${(await PackageInfo.fromPlatform()).version}'; + HttpClient httpClient = HttpClient(); + httpClient.badCertificateCallback = ((_, __, ___) => !sslVerify); + httpClient.userAgent = 'privacyIDEA-App /' + ' ${Platform.operatingSystem}' + ' ${(await PackageInfo.fromPlatform()).version}'; - IOClient ioClient = IOClient(httpClient); + IOClient ioClient = IOClient(httpClient); - Response response; - try { - response = await ioClient.post(url, body: body); - } on SocketException catch (e, s) { - response = Response('${e.runtimeType} : $s', 404); - } + Response response; + try { + response = await ioClient.post(url, body: body); + } on SocketException catch (e, s) { + response = Response('${e.runtimeType} : $s', 404); + } - if (response.statusCode != 200) { - Logger.warning( - 'Received unexpected response', - name: 'utils.dart#doPost', - error: 'Status code: ${response.statusCode}' '\nPosted body: $body' '\nResponse: ${response.body}\n', - ); - } - ioClient.close(); + if (response.statusCode != 200) { + Logger.warning( + 'Received unexpected response', + name: 'utils.dart#doPost', + error: 'Status code: ${response.statusCode}' '\nPosted body: $body' '\nResponse: ${response.body}\n', + ); + } + ioClient.close(); - return response; -} + return response; + } -Future doGet({required Uri url, required Map parameters, bool sslVerify = true}) async { - List entries = parameters.entries.where((element) => element.value == null).toList(); - if (entries.isNotEmpty) { - List nullEntries = []; - for (MapEntry entry in entries) { - nullEntries.add(entry.key); + Future doGet({required Uri url, required Map parameters, bool sslVerify = true}) async { + if (kIsWeb) return Response('', 405); + Logger.info('Sending get request (SSLVerify: $sslVerify)', name: 'utils.dart#doGet'); + List entries = parameters.entries.where((element) => element.value == null).toList(); + if (entries.isNotEmpty) { + List nullEntries = []; + for (MapEntry entry in entries) { + nullEntries.add(entry.key); + } + throw ArgumentError("Can not send request because the argument [parameters] contains " + "null values at entries $nullEntries, this is not permitted."); } - throw ArgumentError("Can not send request because the argument [parameters] contains " - "null values at entries $nullEntries, this is not permitted."); - } - HttpClient httpClient = HttpClient(); - httpClient.badCertificateCallback = ((X509Certificate cert, String host, int port) => !sslVerify); - httpClient.userAgent = 'privacyIDEA-App /' - ' ${Platform.operatingSystem}' - ' ${(await PackageInfo.fromPlatform()).version}'; + HttpClient httpClient = HttpClient(); + httpClient.badCertificateCallback = ((X509Certificate cert, String host, int port) => !sslVerify); + httpClient.userAgent = 'privacyIDEA-App /' + ' ${Platform.operatingSystem}' + ' ${(await PackageInfo.fromPlatform()).version}'; - IOClient ioClient = IOClient(httpClient); + IOClient ioClient = IOClient(httpClient); - StringBuffer buffer = StringBuffer(url); + StringBuffer buffer = StringBuffer(url); - if (parameters.isNotEmpty) { - buffer.write('?'); - buffer.writeAll(parameters.entries.map((e) => '${e.key}=${e.value}'), '&'); - } + if (parameters.isNotEmpty) { + buffer.write('?'); + buffer.writeAll(parameters.entries.map((e) => '${e.key}=${e.value}'), '&'); + } - Response response; - Uri uri = Uri.parse(buffer.toString()); - try { - response = await ioClient.get(uri); - } on SocketException catch (e, s) { - response = Response('${e.runtimeType} : $s', 404); - } on HandshakeException catch (e, s) { - Logger.warning('Handshake failed. sslVerify: $sslVerify', name: 'utils.dart#doGet', error: '$e\n$s'); - showMessage(message: 'Handshake failed, please check the server certificate and try again.'); - rethrow; - } + Response response; + Uri uri = Uri.parse(buffer.toString()); + try { + response = await ioClient.get(uri); + } on SocketException catch (e, s) { + response = Response('${e.runtimeType} : $s', 404); + } on HandshakeException catch (e, s) { + Logger.warning('Handshake failed. sslVerify: $sslVerify', name: 'utils.dart#doGet', error: e, stackTrace: s); + showMessage(message: 'Handshake failed, please check the server certificate and try again.'); + rethrow; + } - if (response.statusCode != 200) { - Logger.warning( - 'Received unexpected response', - name: 'utils.dart#doGet', - error: 'Status code: ${response.statusCode}' '\nuri: $uri' '\nResponse: ${response.body}', - ); - } + if (response.statusCode != 200) { + Logger.warning('Received unexpected response', name: 'utils.dart#doGet'); + } - ioClient.close(); - return response; + ioClient.close(); + return response; + } } diff --git a/lib/utils/parsing_utils.dart b/lib/utils/parsing_utils.dart deleted file mode 100644 index f0ecc771e..000000000 --- a/lib/utils/parsing_utils.dart +++ /dev/null @@ -1,573 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:asn1lib/asn1lib.dart'; -import 'package:http/http.dart'; -import 'package:pointycastle/export.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; -import 'package:privacyidea_authenticator/utils/utils.dart'; - -import 'identifiers.dart'; - -/// Extract RSA-Public-Keys from DER structure that is a BASE64 encoded Strings. -/// According to the PKCS#1 format: -/// -/// RSAPublicKey ::= SEQUENCE { -/// modulus INTEGER, -- n -/// publicExponent INTEGER -- e -/// } -RSAPublicKey deserializeRSAPublicKeyPKCS1(String keyStr) { - ASN1Sequence asn1sequence = ASN1Parser(base64.decode(keyStr)).nextObject() as ASN1Sequence; - BigInt modulus = (asn1sequence.elements[0] as ASN1Integer).valueAsBigInteger; - BigInt exponent = (asn1sequence.elements[1] as ASN1Integer).valueAsBigInteger; - - return RSAPublicKey(modulus, exponent); -} - -/// Convert an RSA-Public-Key to a DER structure as a BASE64 encoded String. -/// According to the PKCS#1 format: -/// -/// RSAPublicKey ::= SEQUENCE { -/// modulus INTEGER, -- n -/// publicExponent INTEGER -- e -/// } -String serializeRSAPublicKeyPKCS1(RSAPublicKey publicKey) { - ASN1Sequence s = ASN1Sequence() - ..add(ASN1Integer(publicKey.modulus!)) - ..add(ASN1Integer(publicKey.exponent!)); - - return base64.encode(s.encodedBytes); -} - -/// Extract RSA-Public-Keys from DER structure that is a BASE64 encoded Strings. -/// According to the PKCS#8 format: -/// -/// PublicKeyInfo ::= SEQUENCE { -/// algorithm AlgorithmIdentifier, -/// PublicKey BIT STRING -/// } -/// -/// AlgorithmIdentifier ::= SEQUENCE { -/// algorithm OBJECT IDENTIFIER, -/// parameters ANY DEFINED BY algorithm OPTIONAL -/// } -RSAPublicKey deserializeRSAPublicKeyPKCS8(String keyStr) { - var baseSequence = ASN1Parser(base64.decode(keyStr)).nextObject() as ASN1Sequence; - - var encodedAlgorithm = baseSequence.elements[0]; - - var algorithm = ASN1Parser(encodedAlgorithm.contentBytes()).nextObject() as ASN1ObjectIdentifier; - - if (algorithm.identifier != '1.2.840.113549.1.1.1') { - throw ArgumentError.value( - algorithm.identifier, - 'algorithm.identifier', - 'Identifier of algorgorithm does not math identifier of RSA ' - '(1.2.840.113549.1.1.1).'); - } - - var encodedKey = baseSequence.elements[1]; - - var asn1sequence = ASN1Parser(encodedKey.contentBytes()).nextObject() as ASN1Sequence; - - BigInt modulus = (asn1sequence.elements[0] as ASN1Integer).valueAsBigInteger; - BigInt exponent = (asn1sequence.elements[1] as ASN1Integer).valueAsBigInteger; - - return RSAPublicKey(modulus, exponent); -} - -/// Convert an RSA-Public-Key to a DER structure as a BASE64 encoded String. -/// According to the PKCS#8 format: -/// -/// PublicKeyInfo ::= SEQUENCE { -/// algorithm AlgorithmIdentifier, -/// PublicKey BIT STRING -/// } -/// -/// AlgorithmIdentifier ::= SEQUENCE { -/// algorithm OBJECT IDENTIFIER, -/// parameters ANY DEFINED BY algorithm OPTIONAL -/// } -String serializeRSAPublicKeyPKCS8(RSAPublicKey key) { - ASN1ObjectIdentifier.registerFrequentNames(); - ASN1Sequence algorithm = ASN1Sequence() - ..add(ASN1ObjectIdentifier.fromName('rsaEncryption')) - ..add(ASN1Null()); - - var keySequence = ASN1Sequence() - ..add(ASN1Integer(key.modulus!)) - ..add(ASN1Integer(key.exponent!)); - - var publicKey = ASN1BitString(keySequence.encodedBytes); - - var asn1sequence = ASN1Sequence() - ..add(algorithm) - ..add(publicKey); - return base64.encode(asn1sequence.encodedBytes); -} - -/// Convert an RSA-Private-Key to a DER structure as a BASE64 encoded String. -/// According to the PKCS#1 format: -/// -/// RSAPrivateKey ::= SEQUENCE { -/// version Version, -/// modulus INTEGER, -- n -/// publicExponent INTEGER, -- e -/// privateExponent INTEGER, -- d -/// prime1 INTEGER, -- p -/// prime2 INTEGER, -- q -/// exponent1 INTEGER, -- d mod (p-1) -/// exponent2 INTEGER, -- d mod (q-1) -/// coefficient INTEGER, -- (inverse of q) mod p -/// otherPrimeInfos OtherPrimeInfos OPTIONAL -/// } -/// -/// Version ::= INTEGER { two-prime(0), multi(1) } -/// (CONSTRAINED BY {-- version must be multi if otherPrimeInfos present --}) -String serializeRSAPrivateKeyPKCS1(RSAPrivateKey key) { - ASN1Sequence s = ASN1Sequence() - ..add(ASN1Integer.fromInt(0)) // version - ..add(ASN1Integer(key.modulus!)) // modulus - ..add(ASN1Integer(key.exponent!)) // e - ..add(ASN1Integer(key.privateExponent!)) // d - ..add(ASN1Integer(key.p!)) // p - ..add(ASN1Integer(key.q!)) // q - ..add(ASN1Integer(key.privateExponent! % (key.p! - BigInt.one))) // d mod (p-1) - ..add(ASN1Integer(key.privateExponent! % (key.q! - BigInt.one))) // d mod (q-1) - ..add(ASN1Integer(key.q!.modInverse(key.p!))); // q^(-1) mod p - - return base64.encode(s.encodedBytes); -} - -/// Extract RSA-Private-Keys from DER structure that is a BASE64 encoded Strings. -/// According to the PKCS#1 format: -/// -/// RSAPrivateKey ::= SEQUENCE { -/// version Version, -/// modulus INTEGER, -- n -/// publicExponent INTEGER, -- e -/// privateExponent INTEGER, -- d -/// prime1 INTEGER, -- p -/// prime2 INTEGER, -- q -/// exponent1 INTEGER, -- d mod (p-1) -/// exponent2 INTEGER, -- d mod (q-1) -/// coefficient INTEGER, -- (inverse of q) mod p -/// otherPrimeInfos OtherPrimeInfos OPTIONAL -/// } -/// -/// Version ::= INTEGER { two-prime(0), multi(1) } -/// (CONSTRAINED BY {-- version must be multi if otherPrimeInfos present --}) -RSAPrivateKey deserializeRSAPrivateKeyPKCS1(String keyStr) { - ASN1Sequence asn1sequence = ASN1Parser(base64.decode(keyStr)).nextObject() as ASN1Sequence; - BigInt modulus = (asn1sequence.elements[1] as ASN1Integer).valueAsBigInteger; - BigInt exponent = (asn1sequence.elements[2] as ASN1Integer).valueAsBigInteger; - BigInt p = (asn1sequence.elements[4] as ASN1Integer).valueAsBigInteger; - BigInt q = (asn1sequence.elements[5] as ASN1Integer).valueAsBigInteger; - - return RSAPrivateKey(modulus, exponent, p, q); -} - -/// The method returns a map that contains all the uri parameters. -Map parseQRCodeToMap(String uriAsString) { - Uri uri = Uri.parse(uriAsString); - Logger.info( - 'Barcode is valid Uri:', - name: 'parsing_utils.dart#parseQRCodeToMap', - error: uri, - ); - - // TODO Parse crash report recipients - - if (uri.scheme != 'otpauth') { - throw ArgumentError.value( - uri, - 'parsing_utils.dart#parseQRCodeToMap', - 'The uri is not a valid otpauth uri but a(n) [${uri.scheme}] uri instead.', - ); - } - - String type = uri.host; - if (equalsIgnoreCase(type, enumAsString(TokenTypes.HOTP)) || - equalsIgnoreCase(type, enumAsString(TokenTypes.TOTP)) || - equalsIgnoreCase(type, enumAsString(TokenTypes.DAYPASSWORD))) { - return parseOtpAuth(uri); - } else if (equalsIgnoreCase(type, enumAsString(TokenTypes.PIPUSH))) { - return parsePiAuth(uri); - } - - throw ArgumentError.value( - uri, - 'parsing_utils.dart#parseQRCodeToMap', - 'The token type [$type] is not supported.', - ); -} - -Map parsePiAuth(Uri uri) { - // otpauth://pipush/LABELTEXT? - // url=https://privacyidea.org/enroll/this/token - // &ttl=120 - // &serial=PIPU0006EF87 - // &projectid=test-d1231 - // &appid=1:0123456789012:android:0123456789abcdef - // &apikey=AIzaSyBeFSjwJ8aEcHQaj4-isT-sLAX6lmSrvbb - // &projectnumber=850240559999 - // &enrollment_credential=9311ee50678983c0f29d3d843f86e39405e2b427 - // &apikeyios=AIzaSyBeFSjwJ8aEcHQaj4-isT-sLAX6lmSrvbb - // &appidios=1:0123456789012:ios:0123456789abcdef - - Map uriMap = {}; - - uriMap[URI_TYPE] = uri.host; - - // If we do not support the version of this piauth url, we can stop here. - String? pushVersionAsString = uri.queryParameters['v']; - - if (pushVersionAsString == null) { - throw ArgumentError.value(uri, 'uri', 'Parameter [v] is not an optional parameter and is missing.'); - } - - try { - int pushVersion = int.parse(pushVersionAsString); - - Logger.info('Parsing push token with version: $pushVersion', name: 'parsing_utils.dart#parsePiAuth'); - - if (pushVersion > 1) { - throw ArgumentError.value( - uri, - 'uri', - 'The piauth version [$pushVersionAsString] ' - 'is not supported by this version of the app.'); - } - } on FormatException { - throw ArgumentError.value(uri, 'uri', '[$pushVersionAsString] is not a valid value for parameter [v].'); - } - - if (uri.queryParameters['image'] != null) { - uriMap[URI_IMAGE] = uri.queryParameters['image']; - } - - final (label, issuer) = _parseLabelAndIssuer(uri); - uriMap[URI_LABEL] = label; - uriMap[URI_ISSUER] = issuer; - - uriMap[URI_SERIAL] = uri.queryParameters['serial']; - ArgumentError.checkNotNull(uriMap[URI_SERIAL], 'serial'); - - String? url = uri.queryParameters['url']; - ArgumentError.checkNotNull(url); - try { - uriMap[URI_ROLLOUT_URL] = Uri.parse(url!); - } on FormatException catch (e) { - throw ArgumentError.value(uri, 'uri', '[$url] is not a valid Uri. Error: ${e.message}'); - } - - String ttlAsString = uri.queryParameters['ttl'] ?? '10'; - try { - uriMap[URI_TTL] = int.parse(ttlAsString); - } on FormatException { - throw ArgumentError.value(uri, 'uri', '[$ttlAsString] is not a valid value for parameter [ttl].'); - } - - uriMap[URI_ENROLLMENT_CREDENTIAL] = uri.queryParameters['enrollment_credential']; - ArgumentError.checkNotNull(uriMap[URI_ENROLLMENT_CREDENTIAL], 'enrollment_credential'); - - uriMap[URI_SSL_VERIFY] = (uri.queryParameters['sslverify'] ?? '1') == '1'; - - // parse pin from response 'True' - if (uri.queryParameters['pin'] == 'True') { - uriMap[URI_PIN] = true; - } - - return uriMap; -} - -/// This method parses otpauth uris according -/// to https://github.com/google/google-authenticator/wiki/Key-Uri-Format. -Map parseOtpAuth(Uri uri) { - // otpauth://TYPE/LABEL?PARAMETERS - - Map uriMap = {}; - - // parse.host -> Type totp or hotp - uriMap[URI_TYPE] = uri.host; - - // parse.path.substring(1) -> Label - Logger.info('Key: [..] | Value: [..]', name: 'parsing_utils.dart#parseOtpAuth'); - uri.queryParameters.forEach((key, value) { - Logger.info(' $key | $value', name: 'parsing_utils.dart#parseOtpAuth'); - }); - - final (label, issuer) = _parseLabelAndIssuer(uri); - uriMap[URI_LABEL] = label; - uriMap[URI_ISSUER] = issuer; - - // parse pin from response 'True' - if (uri.queryParameters['pin'] == 'True') { - uriMap[URI_PIN] = true; - } - - if (uri.queryParameters['image'] != null) { - uriMap[URI_IMAGE] = uri.queryParameters['image']; - } - - String algorithm = uri.queryParameters['algorithm'] ?? enumAsString(Algorithms.SHA1); // Optional parameter - - if (!equalsIgnoreCase(algorithm, enumAsString(Algorithms.SHA1)) && - !equalsIgnoreCase(algorithm, enumAsString(Algorithms.SHA256)) && - !equalsIgnoreCase(algorithm, enumAsString(Algorithms.SHA512))) { - throw ArgumentError.value( - uri, - 'uri', - 'The algorithm [$algorithm] is not supported', - ); - } - - uriMap[URI_ALGORITHM] = algorithm; - - // Parse digits. - String digitsAsString = uri.queryParameters['digits'] ?? '6'; // Optional parameter - - if (digitsAsString != '6' && digitsAsString != '8') { - throw ArgumentError.value( - uri, - 'uri', - '[$digitsAsString] is not a valid number of digits', - ); - } - - int digits = int.parse(digitsAsString); - - uriMap[URI_DIGITS] = digits; - - // Parse secret. - String? secretAsString = uri.queryParameters['secret']; - ArgumentError.checkNotNull(secretAsString); - - // This is a fix for omitted padding in base32 encoded secrets. - // - // According to https://github.com/google/google-authenticator/wiki/Key-Uri-Format, - // the padding can be omitted, but the libraries for base32 do not allow this. - if (secretAsString!.length % 2 == 1) { - secretAsString += '='; - } - secretAsString = secretAsString.toUpperCase(); - if (!isValidEncoding(secretAsString, Encodings.base32)) { - throw ArgumentError.value( - uri, - 'uri', - '[${enumAsString(Encodings.base32)}] is not a valid encoding for [$secretAsString].', - ); - } - - Uint8List secret = decodeSecretToUint8(secretAsString, Encodings.base32); - - uriMap[URI_SECRET] = secret; - - if (uriMap[URI_TYPE] == 'hotp') { - // Parse counter. - String? counterAsString = uri.queryParameters['counter']; - try { - if (counterAsString == null) { - throw ArgumentError.value( - uri, - 'uri', - 'Value for parameter [counter] is not optional and is missing.', - ); - } - uriMap[URI_COUNTER] = int.parse(counterAsString); - } on FormatException { - throw ArgumentError.value( - uri, - 'uri', - '[$counterAsString] is not a valid value for uri parameter [counter].', - ); - } - } - - if (uriMap[URI_TYPE] == 'totp' || uriMap[URI_TYPE] == 'daypassword') { - // Parse period. - String periodAsString = uri.queryParameters['period'] ?? '30'; - - int? period = int.tryParse(periodAsString); - if (period == null) { - throw ArgumentError('Value [$periodAsString] for parameter [period] is invalid.'); - } - uriMap[URI_PERIOD] = period; - } - - if (is2StepURI(uri)) { - // Parse for 2 step roll out. - String saltLengthAsString = uri.queryParameters['2step_salt'] ?? '10'; - String outputLengthInByteAsString = uri.queryParameters['2step_output'] ?? '20'; - String iterationsAsString = uri.queryParameters['2step_difficulty'] ?? '10000'; - - // Parse parameters - try { - uriMap[URI_SALT_LENGTH] = int.parse(saltLengthAsString); - } on FormatException { - throw ArgumentError.value( - uri, - 'uri', - '[$saltLengthAsString] is not a valid value for parameter [2step_salt].', - ); - } - try { - uriMap[URI_OUTPUT_LENGTH_IN_BYTES] = int.parse(outputLengthInByteAsString); - } on FormatException { - throw ArgumentError.value( - uri, - 'uri', - '[$outputLengthInByteAsString] is not a valid value for parameter [2step_output].', - ); - } - try { - uriMap[URI_ITERATIONS] = int.parse(iterationsAsString); - } on FormatException { - throw ArgumentError.value( - uri, - 'uri', - '[$iterationsAsString] is not a valid value for parameter [2step_difficulty].', - ); - } - } - - return uriMap; -} - -/// Parse the label and the issuer (if it exists) from the url. -(String, String) _parseLabelAndIssuer(Uri uri) { - String label = ''; - String issuer = ''; - String param = uri.path.substring(1); - param = Uri.decodeFull(param); - - try { - if (param.contains(':')) { - List split = param.split(':'); - issuer = split[0]; - label = split[1]; - } else { - label = param; - issuer = _parseIssuer(uri); - } - } on Error { - label = param; - } - - return (label, issuer); -} - -String _parseIssuer(Uri uri) { - String? issuer; - String? param = uri.queryParameters['issuer']; - - try { - issuer = Uri.decodeFull(param!); - } on Error { - issuer = param; - } - - return issuer ?? ''; -} - -bool is2StepURI(Uri uri) { - return uri.queryParameters['2step_salt'] != null || uri.queryParameters['2step_output'] != null || uri.queryParameters['2step_difficulty'] != null; -} - -String? getErrorMessageFromResponse(Response response) { - String body = response.body; - String? errorMessage; - try { - final json = jsonDecode(body) as Map; - errorMessage = json['result']?['error']?['message'] as String?; - } catch (e) { - errorMessage = null; - } - if (errorMessage == null) { - final statusMessage = statusMessageFromCode[response.statusCode]; - if (statusMessage != null) { - errorMessage = '${response.statusCode}: $statusMessage'; - } else { - errorMessage = 'Status Code: ${response.statusCode}'; - } - } - return errorMessage; -} - -Map statusMessageFromCode = { - 100: "Continue", - 101: "Switching Protocols", - 102: "Processing", - 200: "OK", - 201: "Created", - 202: "Accepted", - 203: "Non Authoritative Information", - 204: "No Content", - 205: "Reset Content", - 206: "Partial Content", - 207: "Multi-Status", - 300: "Multiple Choices", - 301: "Moved Permanently", - 302: "Moved Temporarily", - 303: "See Other", - 304: "Not Modified", - 305: "Use Proxy", - 307: "Temporary Redirect", - 308: "Permanent Redirect", - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Request Entity Too Large", - 414: "Request-URI Too Long", - 415: "Unsupported Media Type", - 416: "Requested Range Not Satisfiable", - 417: "Expectation Failed", - 418: "I'm a teapot", - 419: "Insufficient Space on Resource", - 420: "Method Failure", - 422: "Unprocessable Entity", - 423: "Locked", - 424: "Failed Dependency", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 507: "Insufficient Storage", - 511: "Network Authentication Required" -}; diff --git a/lib/utils/push_provider.dart b/lib/utils/push_provider.dart index fc62a2a50..162200712 100644 --- a/lib/utils/push_provider.dart +++ b/lib/utils/push_provider.dart @@ -18,168 +18,226 @@ limitations under the License. */ +import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http/http.dart'; -import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; -import 'package:privacyidea_authenticator/utils/customizations.dart'; -import 'package:privacyidea_authenticator/utils/parsing_utils.dart'; -import 'package:privacyidea_authenticator/utils/riverpod_providers.dart'; -import 'package:privacyidea_authenticator/utils/storage_utils.dart'; -import 'package:privacyidea_authenticator/utils/view_utils.dart'; - -import 'crypto_utils.dart'; -import 'identifiers.dart'; + +import '../l10n/app_localizations.dart'; +import '../model/push_request.dart'; +import '../model/tokens/push_token.dart'; +import '../repo/secure_token_repository.dart'; +import '../state_notifiers/push_request_notifier.dart'; +import 'customizations.dart'; +import 'firebase_utils.dart'; import 'logger.dart'; import 'network_utils.dart'; +import 'riverpod_providers.dart'; +import 'rsa_utils.dart'; +import 'utils.dart'; +import 'view_utils.dart'; -/// This class bundles all logic that is needed to handle PushTokens, e.g., +/// This class bundles all logic that is needed to handle incomig PushRequests, e.g., /// firebase, polling, notifications. -abstract class PushProvider { - static late BackgroundMessageHandler _backgroundHandler; - static late BackgroundMessageHandler _incomingHandler; - - static Future initialize({ - required BackgroundMessageHandler handleIncomingMessage, - required BackgroundMessageHandler backgroundMessageHandler, - }) async { - _incomingHandler = handleIncomingMessage; - _backgroundHandler = backgroundMessageHandler; - - await _initFirebase(); +class PushProvider { + static PushProvider? instance; + bool pollingIsEnabled = false; + bool _initialized = false; + Timer? _pollTimer; + PushRequestNotifier? pushSubscriber; // must be set before receiving push messages + FirebaseUtils? firebaseUtils; + PrivacyIdeaIOClient _ioClient; + RsaUtils _rsaUtils; + PushProvider._({PrivacyIdeaIOClient? ioClient, RsaUtils? rsaUtils}) + : _ioClient = ioClient ?? const PrivacyIdeaIOClient(), + _rsaUtils = rsaUtils ?? const RsaUtils(); + + Future initialize({required PushRequestNotifier pushSubscriber, required FirebaseUtils firebaseUtils}) async { + if (_initialized) return; + _initialized = true; + this.firebaseUtils = firebaseUtils; + this.pushSubscriber = pushSubscriber; + await firebaseUtils.initFirebase( + foregroundHandler: _foregroundHandler, + backgroundHandler: _backgroundHandler, + updateFirebaseToken: updateFirebaseToken, + ); } - static Future _initFirebase() async { - await Firebase.initializeApp(); + void setPollingEnabled(bool? enablePolling) { + if (enablePolling == null) return; + _startOrStopPolling(enablePolling); + pollingIsEnabled = enablePolling; + } - try { - await FirebaseMessaging.instance.requestPermission( - alert: false, - badge: false, - sound: false, - ); - } on FirebaseException catch (e, s) { - Logger.warning( - 'e.code: ${e.code}, ' - 'e.message: ${e.message}, ' - 'e.plugin: ${e.plugin},', - name: 'push_provider.dart#_initFirebase', - error: e, - stackTrace: s, - ); - String errorMessage = e.message ?? 'no error message'; - final SnackBar snackBar = SnackBar( - content: Text( - "Firebase notification permission error! ($errorMessage: ${e.code}", - )); - globalSnackbarKey.currentState?.showSnackBar(snackBar); + factory PushProvider({ + bool? pollingEnabled, + PrivacyIdeaIOClient? ioClient, + RsaUtils? rsaUtils, + }) { + if (instance == null) { + instance = PushProvider._(ioClient: ioClient, rsaUtils: rsaUtils); + } else { + if (ioClient != null) { + instance!._ioClient = ioClient; + } + if (rsaUtils != null) { + instance!._rsaUtils = rsaUtils; + } } - FirebaseMessaging.onMessage.listen(_incomingHandler); - FirebaseMessaging.onBackgroundMessage(_backgroundHandler); + instance!.setPollingEnabled(pollingEnabled); - try { - String? firebaseToken = await getFBToken(); + return instance!; + } - if (firebaseToken != await StorageUtil.getCurrentFirebaseToken()) { - _updateFirebaseToken(firebaseToken); - } - } on PlatformException catch (error) { - if (error.code == FIREBASE_TOKEN_ERROR_CODE) { - // ignore - } else { - String errorMessage = error.message ?? 'no error message'; - final SnackBar snackBar = SnackBar( - content: Text( - 'Push cant be initialized, restart the app and try again. ${error.code}: $errorMessage', - overflow: TextOverflow.fade, - softWrap: false, - )); - globalSnackbarKey.currentState?.showSnackBar(snackBar); + // INITIALIZATIONS + + // FOREGROUND HANDLING + Future _foregroundHandler(RemoteMessage remoteMessage) async { + Logger.info('Foreground message received.', name: 'push_provider.dart#_foregroundHandler'); + await SecureTokenRepository.protect(() async { + try { + return _handleIncomingRequestForeground(remoteMessage); + } on TypeError catch (e, s) { + final errorMessage = AppLocalizations.of(globalNavigatorKey.currentContext!)!.incomingAuthRequestError; + showMessage(message: errorMessage); + Logger.warning(errorMessage, name: 'push_provider.dart#_foregroundHandler', error: e, stackTrace: s); + } catch (e, s) { + final errorMessage = AppLocalizations.of(globalNavigatorKey.currentContext!)!.unexpectedError; + Logger.error(errorMessage, name: 'push_provider.dart#_foregroundHandler', error: e, stackTrace: s); } - } on FirebaseException catch (error) { - final SnackBar snackBar = SnackBar( - content: Text( - "Push cant be initialized, restart the app and try again$error", - overflow: TextOverflow.fade, - softWrap: false, - )); - globalSnackbarKey.currentState?.showSnackBar(snackBar); - } catch (error) { - final SnackBar snackBar = SnackBar( - content: Text( - "Unknown error: $error", - overflow: TextOverflow.fade, - softWrap: false, - )); - globalSnackbarKey.currentState?.showSnackBar(snackBar); - } + }); + } - FirebaseMessaging.instance.onTokenRefresh.listen((String newToken) async { - if ((await StorageUtil.getCurrentFirebaseToken()) != newToken) { - await StorageUtil.setNewFirebaseToken(newToken); - // TODO what if this fails, when should a retry be attempted? - try { - _updateFirebaseToken(newToken); - } catch (error) { - final SnackBar snackBar = SnackBar( - content: Text( - "Unknown error: $error", - overflow: TextOverflow.fade, - softWrap: false, - )); - globalSnackbarKey.currentState?.showSnackBar(snackBar); - } + // BACKGROUND HANDLING + static Future _backgroundHandler(RemoteMessage remoteMessage) async { + Logger.info('Background message received.', name: 'push_provider.dart#_backgroundHandler'); + await SecureTokenRepository.protect(() async { + try { + return _handleIncomingRequestBackground(remoteMessage); + } on TypeError catch (e, s) { + final errorMessage = AppLocalizations.of(globalNavigatorKey.currentContext!)!.incomingAuthRequestError; + Logger.warning(errorMessage, name: 'push_provider.dart#_backgroundHandler', error: e, stackTrace: s); + } catch (e, s) { + final errorMessage = AppLocalizations.of(globalNavigatorKey.currentContext!)!.unexpectedError; + Logger.error(errorMessage, name: 'push_provider.dart#_backgroundHandler', error: e, stackTrace: s); } }); } - /// Returns the current firebase token of the app / device. Throws a - /// PlatformException with a custom error code if retrieving the firebase - /// token failed. This may happen if, e.g., no network connection is available. - static Future getFBToken() async { - String? firebaseToken; - try { - firebaseToken = await FirebaseMessaging.instance.getToken(); - } on FirebaseException catch (e, s) { - String errorMessage = e.message ?? 'no error message'; - final SnackBar snackBar = SnackBar( - content: Text( - "Unable to retrieve Firebase token! ($errorMessage: ${e.code})", - overflow: TextOverflow.fade, - softWrap: false, - ), - ); - Logger.warning('Unable to retrieve Firebase token! ($errorMessage: ${e.code})', name: 'push_provider.dart#getFBToken', error: e, stackTrace: s); - globalSnackbarKey.currentState?.showSnackBar(snackBar); + // HANDLING + /// Handles incoming push requests by verifying the challenge and adding it + /// to the token. This should be guarded by a lock. + Future _handleIncomingRequestForeground(RemoteMessage message) async { + final data = message.data; + Logger.info('Incoming push challenge.', name: 'push_provider.dart#_handleIncomingRequestForeground'); + Uri? requestUri = Uri.tryParse(data['url']); + if (requestUri == null || + data['nonce'] == null || + data['serial'] == null || + data['signature'] == null || + data['title'] == null || + data['question'] == null) { + Logger.warning('Could not parse url. Some required parameters are missing.', name: 'push_provider.dart#_handleIncomingRequestForeground'); + return; } - // Fall back to the last known firebase token - if (firebaseToken == null) { - firebaseToken = await StorageUtil.getCurrentFirebaseToken(); - } else { - await StorageUtil.setNewFirebaseToken(firebaseToken); + bool sslVerify = (int.tryParse(data['sslverify']) ?? 0) == 1; + PushRequest pushRequest = PushRequest( + title: data['title'], + question: data['question'], + uri: requestUri, + nonce: data['nonce'], + sslVerify: sslVerify, + id: data['nonce'].hashCode, + // FIXME This is not guaranteed to not lead to collisions, but they might be unlikely in this case. + expirationDate: DateTime.now().add( + const Duration(seconds: 120), // Push requests expire after 2 minutes. + ), + serial: data['serial'], + signature: data['signature'], + ); + + Logger.info('Incoming push challenge for token with serial.', name: 'push_provider.dart#_handleIncomingChallenge'); + + pushSubscriber?.newRequest(pushRequest); + } + + // HANDLING + /// Handles incoming push requests by verifying the challenge and adding it + /// to the token. This should be guarded by a lock. + static Future _handleIncomingRequestBackground(RemoteMessage message) async { + final data = message.data; + Logger.info('Incoming push challenge.', name: 'push_provider.dart#_handleIncomingRequestBackground'); + Uri? requestUri = Uri.tryParse(data['url']); + if (requestUri == null || + data['nonce'] == null || + data['serial'] == null || + data['signature'] == null || + data['title'] == null || + data['question'] == null) { + Logger.warning('Could not parse url. Some required parameters are missing.', name: 'push_provider.dart#_handleIncomingRequestBackground'); + return; } - if (firebaseToken == null) { - // This error should be handled in all cases, the user might be informed - // in the form of a pop-up message. - throw PlatformException( - message: 'Firebase token could not be retrieved, the only know cause of this is' - ' that the firebase servers could not be reached.', - code: FIREBASE_TOKEN_ERROR_CODE); + bool sslVerify = (int.tryParse(data['sslverify']) ?? 0) == 1; + PushRequest pushRequest = PushRequest( + title: data['title'], + question: data['question'], + uri: requestUri, + nonce: data['nonce'], + sslVerify: sslVerify, + id: data['nonce'].hashCode, + // FIXME This is not guaranteed to not lead to collisions, but they might be unlikely in this case. + expirationDate: DateTime.now().add( + const Duration(seconds: 120), // Push requests expire after 2 minutes. + ), + serial: data['serial'], + signature: data['signature'], + ); + + Logger.info('Incoming push challenge for token with serial.', name: 'push_provider.dart#_handleIncomingRequestBackground'); + _addPushRequestToTokenInSecureStoreage(pushRequest); + } + + static void _addPushRequestToTokenInSecureStoreage(PushRequest pushRequest) async { + Logger.info('Adding push request to token in secure storage.', name: 'push_provider.dart#_addPushRequestToTokenInSecureStoreage'); + var tokens = await const SecureTokenRepository().loadTokens(); + PushToken? token = tokens.firstWhereOrNull((token) => token is PushToken && token.serial == pushRequest.serial) as PushToken?; + if (token == null) { + Logger.warning('Token not found.', name: 'push_provider.dart#_addPushRequestToTokenInSecureStoreage'); + return; } + final prList = token.pushRequests; + prList.add(pushRequest); + token = token.copyWith(pushRequests: prList); + await const SecureTokenRepository().saveOrReplaceTokens([token]); + } - return firebaseToken; + void _startOrStopPolling(bool pollingEnabled) { + // Start polling if enabled and not already polling + if (pollingEnabled && _pollTimer == null) { + Logger.info('Polling is enabled.', name: 'push_provider.dart#_startPollingIfEnabled'); + _pollTimer = Timer.periodic(const Duration(seconds: 3), (_) => pollForChallenges()); + pollForChallenges(); + return; + } + // Stop polling if it's disabled and currently polling + if (!pollingEnabled && _pollTimer != null) { + Logger.info('Polling is disabled.', name: 'push_provider.dart#_startPollingIfEnabled'); + _pollTimer?.cancel(); + _pollTimer = null; + return; + } + // Do nothing if polling is enabled and already polling or disabled and not polling + return; } - static Future pollForChallenges({bool showMessageForEachToken = false}) async { + Future pollForChallenges({bool showMessageForEachToken = false}) async { final connectivityResult = await (Connectivity().checkConnectivity()); if (connectivityResult == ConnectivityResult.none) { Logger.info('Tried to poll without any internet connection available.', name: 'push_provider.dart#pollForChallenges'); @@ -192,7 +250,7 @@ abstract class PushProvider { // Disable polling if no push tokens exist if (pushTokens.isEmpty && globalRef?.read(settingsProvider).enablePolling == true) { Logger.info('No push token is available for polling, polling is disabled.', name: 'push_provider.dart#pollForChallenges'); - globalRef?.read(settingsProvider.notifier).disablePolling(); + globalRef?.read(settingsProvider.notifier).setPolling(false); return null; } @@ -202,6 +260,7 @@ abstract class PushProvider { pollForChallenge(p).then((errorMessage) { if (errorMessage != null && showMessageForEachToken) { Logger.warning(errorMessage, name: 'push_provider.dart#pollForChallenges'); + // TODO: Improve error message showMessage(message: errorMessage); } }); @@ -209,12 +268,14 @@ abstract class PushProvider { return null; } - static Future pollForChallenge(PushToken token) async { + Future pollForChallenge(PushToken token) async { String timestamp = DateTime.now().toUtc().toIso8601String(); String message = '${token.serial}|$timestamp'; - String? signature = await trySignWithToken(token, message); + RsaUtils rsaUtils = instance == null ? PushProvider()._rsaUtils : instance!._rsaUtils; + Logger.info(rsaUtils.runtimeType.toString(), name: 'push_provider.dart#pollForChallenge'); + String? signature = await rsaUtils.trySignWithToken(token, message); if (signature == null) { Logger.warning('Polling push tokens failed because signing the message failed.', name: 'push_provider.dart#pollForChallenge'); return null; @@ -226,7 +287,9 @@ abstract class PushProvider { }; try { - Response response = await doGet(url: token.url!, parameters: parameters, sslVerify: token.sslVerify); + Response response = instance != null + ? await instance!._ioClient.doGet(url: token.url!, parameters: parameters, sslVerify: token.sslVerify) + : await const PrivacyIdeaIOClient().doGet(url: token.url!, parameters: parameters, sslVerify: token.sslVerify); switch (response.statusCode) { case 200: @@ -236,13 +299,13 @@ abstract class PushProvider { List challengeList = result['value'].cast>(); for (Map challenge in challengeList) { - Logger.info('Received challenge ${challenge['nonce']}', name: 'push_provider.dart#pollForChallenge'); - _incomingHandler(RemoteMessage(data: challenge)); + Logger.info('Received challenge', name: 'push_provider.dart#pollForChallenge'); + _foregroundHandler(RemoteMessage(data: challenge)); } break; case 403: - Logger.warning('Polling push token ${token.serial} failed with status code ${response.statusCode}', + Logger.warning('Polling push token failed with status code ${response.statusCode}', name: 'push_provider.dart#pollForChallenge', error: getErrorMessageFromResponse(response)); return null; @@ -258,43 +321,48 @@ abstract class PushProvider { /// Checks if the firebase token was changed and updates it if necessary. static Future updateFbTokenIfChanged() async { - String? firebaseToken = await getFBToken(); + String? firebaseToken = await instance?.firebaseUtils?.getFBToken(); - if (firebaseToken != null && (await StorageUtil.getCurrentFirebaseToken()) != firebaseToken) { + if (firebaseToken != null && (await SecureTokenRepository.getCurrentFirebaseToken()) != firebaseToken) { try { - _updateFirebaseToken(firebaseToken); - } catch (error) { - final SnackBar snackBar = SnackBar( - content: Text( - "Unknown error: $error", - overflow: TextOverflow.fade, - softWrap: false, - ), - ); - globalSnackbarKey.currentState?.showSnackBar(snackBar); + await updateFirebaseToken(firebaseToken); + } catch (error, stackTrace) { + Logger.error('Could not update firebase token.', name: 'push_provider.dart#updateFbTokenIfChanged', error: error, stackTrace: stackTrace); } } } /// This method attempts to update the fbToken for all PushTokens that can be /// updated. I.e. all tokens that know the url of their respective privacyIDEA - /// server. If the update fails for one or all tokens, this method does *not* - /// give any feedback!. + /// server. + /// If the fbToken is not provided, it will be fetched from the firebase instance. + /// If the fbToken is not available, this method will return null. + /// Returns a tuple of two lists. The first list contains all tokens that + /// could not be updated. The second list contains all tokens that do not + /// support updating the fbToken. /// /// This should only be used to attempt to update the fbToken automatically, /// as this can not be guaranteed to work. There is a manual option available /// through the settings also. - static void _updateFirebaseToken(String? firebaseToken) async { + static Future<(List, List)?> updateFirebaseToken([String? firebaseToken]) async { + firebaseToken ??= await instance?.firebaseUtils?.getFBToken(); if (firebaseToken == null) { - // Nothing to update here! - return; + Logger.warning('Could not update firebase token because no firebase token is available.', name: 'push_provider.dart#_updateFirebaseToken'); + return null; } - List tokenList = (await StorageUtil.loadAllTokens()).whereType().where((t) => t.url != null).toList(); + List tokenList = (await const SecureTokenRepository().loadTokens()).whereType().where((t) => t.url != null).toList(); bool allUpdated = true; + final List failedTokens = []; + final List unsuportedTokens = []; + for (PushToken p in tokenList) { + if (p.url == null) { + unsuportedTokens.add(p); + continue; + } // POST /ttype/push HTTP/1.1 //Host: example.com // @@ -304,27 +372,31 @@ abstract class PushProvider { //signature=SIGNATURE(||) String timestamp = DateTime.now().toUtc().toIso8601String(); - String message = '$firebaseToken|${p.serial}|$timestamp'; - // Because no context is available, trySignWithToken will fail without feedback for the user - // Just like this whole function // TODO improve that? - String? signature = await trySignWithToken(p, message); + String? signature = await const RsaUtils().trySignWithToken(p, message); if (signature == null) { - return; + failedTokens.add(p); + allUpdated = false; + continue; } - Response response = await doPost( - sslVerify: p.sslVerify, url: p.url!, body: {'new_fb_token': firebaseToken, 'serial': p.serial, 'timestamp': timestamp, 'signature': signature}); + Response response = instance != null + ? await instance!._ioClient.doPost( + sslVerify: p.sslVerify, url: p.url!, body: {'new_fb_token': firebaseToken, 'serial': p.serial, 'timestamp': timestamp, 'signature': signature}) + : await const PrivacyIdeaIOClient().doPost( + sslVerify: p.sslVerify, url: p.url!, body: {'new_fb_token': firebaseToken, 'serial': p.serial, 'timestamp': timestamp, 'signature': signature}); if (response.statusCode == 200) { - Logger.info('Updating firebase token for push token: ${p.serial} succeeded!', name: 'push_provider.dart#_updateFirebaseToken'); + Logger.info('Updating firebase token for push token succeeded!', name: 'push_provider.dart#_updateFirebaseToken'); } else { - Logger.warning('Updating firebase token for push token: ${p.serial} failed!', name: 'push_provider.dart#_updateFirebaseToken'); + Logger.warning('Updating firebase token for push token failed!', name: 'push_provider.dart#_updateFirebaseToken'); + failedTokens.add(p); allUpdated = false; } } if (allUpdated) { - StorageUtil.setCurrentFirebaseToken(firebaseToken); + SecureTokenRepository.setCurrentFirebaseToken(firebaseToken); } + return (failedTokens, unsuportedTokens); } } diff --git a/lib/utils/qr_parser.dart b/lib/utils/qr_parser.dart new file mode 100644 index 000000000..4a4c7572a --- /dev/null +++ b/lib/utils/qr_parser.dart @@ -0,0 +1,332 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import 'package:flutter/foundation.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/logger.dart'; +import 'package:privacyidea_authenticator/utils/supported_versions.dart'; +import 'package:privacyidea_authenticator/utils/utils.dart'; + +class QrParser { + const QrParser(); + + /// The method returns a map that contains all the uri parameters. + Map parseQRCodeToMap(String uriAsString) { + Uri uri = Uri.parse(uriAsString); + Logger.info('Barcode is valid Uri:', name: 'parsing_utils.dart#parseQRCodeToMap'); + + if (uri.scheme != 'otpauth') { + throw ArgumentError.value( + 'parsing_utils.dart#parseQRCodeToMap', + 'The uri is not a valid otpauth uri but a(n) [${uri.scheme}] uri instead.', + ); + } + + String type = uri.host; + if (equalsIgnoreCase(type, enumAsString(TokenTypes.HOTP)) || + equalsIgnoreCase(type, enumAsString(TokenTypes.TOTP)) || + equalsIgnoreCase(type, enumAsString(TokenTypes.DAYPASSWORD))) { + return _parseOtpAuth(uri); + } else if (equalsIgnoreCase(type, enumAsString(TokenTypes.PIPUSH))) { + return _parsePiAuth(uri); + } + + throw ArgumentError.value( + 'parsing_utils.dart#parseQRCodeToMap', + 'The token type [$type] is not supported.', + ); + } + + Map _parsePiAuth(Uri uri) { + // otpauth://pipush/LABELTEXT? + // url=https://privacyidea.org/enroll/this/token + // &ttl=120 + // &issuer=privacyIDEA + // &enrollment_credential=9311ee50678983c0f29d3d843f86e39405e2b427 + // &v=1 + // &serial=PIPU0006EF87 + // &sslverify=1 + + Map uriMap = {}; + + uriMap[URI_TYPE] = uri.host; + + // If we do not support the version of this piauth url, we can stop here. + String? pushVersionAsString = uri.queryParameters['v']; + + if (pushVersionAsString == null) { + throw ArgumentError.value(uri, 'uri', 'Parameter [v] is not an optional parameter and is missing.'); + } + + try { + int pushVersion = int.parse(pushVersionAsString); + + Logger.info('Parsing push token with version: $pushVersion', name: 'parsing_utils.dart#parsePiAuth'); + + if (pushVersion > maxPushTokenVersion) { + throw ArgumentError.value( + uri, + 'uri', + 'The piauth version [$pushVersionAsString] ' + 'is not supported by this version of the app.'); + } + } on FormatException { + throw ArgumentError.value(uri, 'uri', '[$pushVersionAsString] is not a valid value for parameter [v].'); + } + + if (uri.queryParameters['image'] != null) { + uriMap[URI_IMAGE] = uri.queryParameters['image']; + } + + final (label, issuer) = _parseLabelAndIssuer(uri); + uriMap[URI_LABEL] = label; + uriMap[URI_ISSUER] = issuer; + + uriMap[URI_SERIAL] = uri.queryParameters['serial']; + ArgumentError.checkNotNull(uriMap[URI_SERIAL], 'serial'); + + String? url = uri.queryParameters['url']; + ArgumentError.checkNotNull(url); + try { + uriMap[URI_ROLLOUT_URL] = Uri.parse(url!); + } on FormatException catch (e) { + throw ArgumentError.value(uri, 'uri', '[$url] is not a valid Uri. Error: ${e.message}'); + } + + String ttlAsString = uri.queryParameters['ttl'] ?? '10'; + try { + uriMap[URI_TTL] = int.parse(ttlAsString); + } on FormatException { + throw ArgumentError.value(uri, 'uri', '[$ttlAsString] is not a valid value for parameter [ttl].'); + } + + uriMap[URI_ENROLLMENT_CREDENTIAL] = uri.queryParameters['enrollment_credential']; + ArgumentError.checkNotNull(uriMap[URI_ENROLLMENT_CREDENTIAL], 'enrollment_credential'); + + uriMap[URI_SSL_VERIFY] = (uri.queryParameters['sslverify'] ?? '1') == '1'; + + // parse pin from response 'True' + if (uri.queryParameters['pin'] == 'True') { + uriMap[URI_PIN] = true; + } + + return uriMap; + } + + /// This method parses otpauth uris according + /// to https://github.com/google/google-authenticator/wiki/Key-Uri-Format. + Map _parseOtpAuth(Uri uri) { + // otpauth://TYPE/LABEL?PARAMETERS + + Map uriMap = {}; + + // parse.host -> Type totp or hotp + uriMap[URI_TYPE] = uri.host; + + // parse.path.substring(1) -> Label + String infoLog = '\nKey: [..] | Value: [..]'; + uri.queryParameters.forEach((key, value) { + if (key == 'secret') { + value = '********'; + } + infoLog += '\n${key.padLeft(9)} | $value'; + }); + Logger.info( + infoLog, + name: 'parsing_utils.dart#_parseOtpAuth', + ); + + final (label, issuer) = _parseLabelAndIssuer(uri); + uriMap[URI_LABEL] = label; + uriMap[URI_ISSUER] = issuer; + + // parse pin from response 'True' + if (uri.queryParameters['pin'] == 'True') { + uriMap[URI_PIN] = true; + } + + if (uri.queryParameters['image'] != null) { + uriMap[URI_IMAGE] = uri.queryParameters['image']; + } + + String algorithm = uri.queryParameters['algorithm'] ?? enumAsString(Algorithms.SHA1); // Optional parameter + + if (!equalsIgnoreCase(algorithm, enumAsString(Algorithms.SHA1)) && + !equalsIgnoreCase(algorithm, enumAsString(Algorithms.SHA256)) && + !equalsIgnoreCase(algorithm, enumAsString(Algorithms.SHA512))) { + throw ArgumentError.value( + uri, + 'uri', + 'The algorithm [$algorithm] is not supported', + ); + } + + uriMap[URI_ALGORITHM] = algorithm; + + // Parse digits. + String digitsAsString = uri.queryParameters['digits'] ?? '6'; // Optional parameter + + if (digitsAsString != '6' && digitsAsString != '8') { + throw ArgumentError.value( + uri, + 'uri', + '[$digitsAsString] is not a valid number of digits', + ); + } + + int digits = int.parse(digitsAsString); + + uriMap[URI_DIGITS] = digits; + + // Parse secret. + String? secretAsString = uri.queryParameters['secret']; + ArgumentError.checkNotNull(secretAsString); + + // This is a fix for omitted padding in base32 encoded secrets. + // + // According to https://github.com/google/google-authenticator/wiki/Key-Uri-Format, + // the padding can be omitted, but the libraries for base32 do not allow this. + if (secretAsString!.length % 2 == 1) { + secretAsString += '='; + } + secretAsString = secretAsString.toUpperCase(); + if (!isValidEncoding(secretAsString, Encodings.base32)) { + throw ArgumentError.value( + uri, + 'uri', + '[${enumAsString(Encodings.base32)}] is not a valid encoding for [$secretAsString].', + ); + } + + Uint8List secret = decodeSecretToUint8(secretAsString, Encodings.base32); + + uriMap[URI_SECRET] = secret; + + if (uriMap[URI_TYPE] == 'hotp') { + // Parse counter. + String? counterAsString = uri.queryParameters['counter']; + try { + if (counterAsString == null) { + throw ArgumentError.value( + uri, + 'uri', + 'Value for parameter [counter] is not optional and is missing.', + ); + } + uriMap[URI_COUNTER] = int.parse(counterAsString); + } on FormatException { + throw ArgumentError.value( + uri, + 'uri', + '[$counterAsString] is not a valid value for uri parameter [counter].', + ); + } + } + + if (uriMap[URI_TYPE] == 'totp' || uriMap[URI_TYPE] == 'daypassword') { + // Parse period. + String periodAsString = uri.queryParameters['period'] ?? '30'; + + int? period = int.tryParse(periodAsString); + if (period == null) { + throw ArgumentError('Value [$periodAsString] for parameter [period] is invalid.'); + } + uriMap[URI_PERIOD] = period; + } + + if (is2StepURI(uri)) { + // Parse for 2 step roll out. + String saltLengthAsString = uri.queryParameters['2step_salt'] ?? '10'; + String outputLengthInByteAsString = uri.queryParameters['2step_output'] ?? '20'; + String iterationsAsString = uri.queryParameters['2step_difficulty'] ?? '10000'; + + // Parse parameters + try { + uriMap[URI_SALT_LENGTH] = int.parse(saltLengthAsString); + } on FormatException { + throw ArgumentError.value( + uri, + 'uri', + '[$saltLengthAsString] is not a valid value for parameter [2step_salt].', + ); + } + try { + uriMap[URI_OUTPUT_LENGTH_IN_BYTES] = int.parse(outputLengthInByteAsString); + } on FormatException { + throw ArgumentError.value( + uri, + 'uri', + '[$outputLengthInByteAsString] is not a valid value for parameter [2step_output].', + ); + } + try { + uriMap[URI_ITERATIONS] = int.parse(iterationsAsString); + } on FormatException { + throw ArgumentError.value( + uri, + 'uri', + '[$iterationsAsString] is not a valid value for parameter [2step_difficulty].', + ); + } + } + + return uriMap; + } + + /// Parse the label and the issuer (if it exists) from the url. + (String, String) _parseLabelAndIssuer(Uri uri) { + String label = ''; + String issuer = ''; + String param = uri.path.substring(1); + param = Uri.decodeFull(param); + + try { + if (param.contains(':')) { + List split = param.split(':'); + issuer = split[0]; + label = split[1]; + } else { + label = param; + issuer = _parseIssuer(uri); + } + } on Error { + label = param; + } + + return (label, issuer); + } + + String _parseIssuer(Uri uri) { + String? issuer; + String? param = uri.queryParameters['issuer']; + + try { + issuer = Uri.decodeFull(param!); + } on Error { + issuer = param; + } + + return issuer ?? ''; + } + + bool is2StepURI(Uri uri) { + return uri.queryParameters['2step_salt'] != null || uri.queryParameters['2step_output'] != null || uri.queryParameters['2step_difficulty'] != null; + } +} diff --git a/lib/utils/riverpod_providers.dart b/lib/utils/riverpod_providers.dart index 89f29c2f6..5efc537f3 100644 --- a/lib/utils/riverpod_providers.dart +++ b/lib/utils/riverpod_providers.dart @@ -1,11 +1,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacyidea_authenticator/state_notifiers/deeplink_notifier.dart'; -import 'package:privacyidea_authenticator/utils/push_provider.dart'; import '../model/mixins/sortable_mixin.dart'; -import '../model/platform_info/platform_info.dart'; -import '../model/platform_info/platform_info_imp/dummy_platform_info.dart'; import '../model/push_request.dart'; import '../model/states/app_state.dart'; import '../model/states/settings_state.dart'; @@ -14,106 +10,119 @@ import '../model/states/token_state.dart'; import '../repo/preference_settings_repository.dart'; import '../repo/preference_token_folder_repository.dart'; import '../state_notifiers/app_state_notifier.dart'; +import '../state_notifiers/deeplink_notifier.dart'; import '../state_notifiers/push_request_notifier.dart'; import '../state_notifiers/settings_notifier.dart'; import '../state_notifiers/token_folder_notifier.dart'; import '../state_notifiers/token_notifier.dart'; +import 'app_customizer.dart'; import 'logger.dart'; +import 'push_provider.dart'; // Never use globalRef to .watch() a provider. only use it to .read() a provider // Otherwise the whole app will rebuild on every state change of the provider WidgetRef? globalRef; final tokenProvider = StateNotifierProvider((ref) { + Logger.info("New TokenNotifier created"); final tokenNotifier = TokenNotifier(); - deeplinkProvider.addListener( - ref.container, - (previous, next) { - if (next == null) return; - Logger.info("tokenProvider received new deeplink"); - tokenNotifier.handleLink(next); - }, - onError: (err, _) => throw err, - onDependencyMayHaveChanged: () {}, - fireImmediately: false, - ); + ref.listen(deeplinkProvider, (previous, newLink) { + if (newLink == null) { + Logger.info("tokenProvider received null deeplink"); + return; + } + Logger.info("tokenProvider received new deeplink"); + tokenNotifier.handleLink(newLink); + }); + + ref.listen(pushRequestProvider, (previous, newPushRequest) { + if (newPushRequest == null) { + Logger.info("tokenProvider received null pushRequest"); + return; + } + if (newPushRequest.accepted == null) { + Logger.info("tokenProvider received new pushRequest"); + tokenNotifier.addPushRequestToToken(newPushRequest); + } + if (newPushRequest.accepted != null) { + Logger.info("tokenProvider received pushRequest with accepted=${newPushRequest.accepted}... removing it from state."); + tokenNotifier.removePushRequest(newPushRequest); + FlutterLocalNotificationsPlugin().cancelAll(); + } + }); - appStateProvider.addListener( - ref.container, + ref.listen( + appStateProvider, (previous, next) { + Logger.info('tokenProvider reviced new AppState. Changed from $previous to $next'); if (previous == AppState.pause && next == AppState.resume) { Logger.info('Refreshing tokens on resume'); - tokenNotifier.refreshTokens(); + tokenNotifier.refreshRolledOutPushTokens(); } }, - onError: (err, stack) { - throw err; - }, - onDependencyMayHaveChanged: () {}, - fireImmediately: true, - ); - - pushRequestProvider.addListener( - ref.container, - (previous, next) { - if (next == null) return; - if (next.accepted == null) { - Logger.info("tokenProvider received new pushRequest"); - tokenNotifier.addPushRequestToToken(next); - return; - } - if (next.accepted != null) { - Logger.info("tokenProvider received pushRequest with accepted=${next.accepted}... removing it from state."); - tokenNotifier.removePushRequest(next); - FlutterLocalNotificationsPlugin().cancelAll(); - return; - } - }, - onError: (err, stack) { - throw err; - }, - onDependencyMayHaveChanged: () {}, - fireImmediately: true, ); return tokenNotifier; }); -final settingsProvider = StateNotifierProvider((ref) => SettingsNotifier( - repository: PreferenceSettingsRepository(), - initialState: SettingsState(), - )); - -final platformInfoProvider = StateProvider( - (ref) => DummyPlatformInfo(), +final settingsProvider = StateNotifierProvider( + (ref) { + // Using Logger here will cause a circular dependency because Logger uses settingsProvider (logging verbosity) + return SettingsNotifier(repository: PreferenceSettingsRepository()); + }, ); final pushRequestProvider = StateNotifierProvider( (ref) { - final pushRequestNotifier = PushRequestNotifier(null, pollingEnabled: ref.watch(settingsProvider).enablePolling); - appStateProvider.addListener( - ref.container, - (previous, next) { - if (previous == AppState.pause && next == AppState.resume) { - Logger.info('Polling for challenges on resume'); - PushProvider.pollForChallenges(); - } - }, - onError: (_, __) {}, - onDependencyMayHaveChanged: () {}, - fireImmediately: false, + Logger.info("New PushRequestNotifier created"); + ref.listen(settingsProvider, (previous, next) { + if (previous?.enablePolling != next.enablePolling) { + Logger.info("Polling enabled changed from ${previous?.enablePolling} to ${next.enablePolling}"); + PushProvider.instance?.setPollingEnabled(next.enablePolling); + } + }); + + final pushProvider = PushProvider(pollingEnabled: false); + final pushRequestNotifier = PushRequestNotifier( + pushProvider: pushProvider, ); + + ref.listen(appStateProvider, (previous, next) { + if (previous == AppState.pause && next == AppState.resume) { + Logger.info('Polling for challenges on resume'); + pushProvider.pollForChallenges(); + } + }); + return pushRequestNotifier; }, ); -final deeplinkProvider = StateNotifierProvider((ref) => DeeplinkNotifier()); +final deeplinkProvider = StateNotifierProvider((ref) { + Logger.info("New DeeplinkNotifier created"); + return DeeplinkNotifier(); +}); final appStateProvider = StateNotifierProvider( - (ref) => AppStateNotifier(), + (ref) { + Logger.info("New AppStateNotifier created"); + return AppStateNotifier(); + }, ); -final tokenFolderProvider = - StateNotifierProvider((ref) => TokenFolderNotifier(repositoy: PreferenceTokenFolderRepotisory())); +final tokenFolderProvider = StateNotifierProvider.autoDispose( + (ref) { + Logger.info("New TokenFolderNotifier created"); + return TokenFolderNotifier( + repository: PreferenceTokenFolderRepository(), + ); + }, +); + +final draggingSortableProvider = StateProvider((ref) { + Logger.info("New draggingSortableProvider created"); + return null; +}); -final draggingSortableProvider = StateProvider((ref) => null); +/// Only used for the app customizer +final applicationCustomizerProvider = StateProvider((ref) => ApplicationCustomization.defaultCustomization); diff --git a/lib/utils/rsa_utils.dart b/lib/utils/rsa_utils.dart new file mode 100644 index 000000000..35e5ba3c0 --- /dev/null +++ b/lib/utils/rsa_utils.dart @@ -0,0 +1,263 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import 'dart:convert'; + +import 'package:asn1lib/asn1lib.dart'; +import 'package:base32/base32.dart'; +import 'package:flutter/foundation.dart'; +import 'package:pi_authenticator_legacy/pi_authenticator_legacy.dart'; +import 'package:pointycastle/export.dart'; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/logger.dart'; + +class RsaUtils { + const RsaUtils(); + + /// Extract RSA-Public-Keys from DER structure that is a BASE64 encoded Strings. + /// According to the PKCS#1 format: + /// + /// RSAPublicKey ::= SEQUENCE { + /// modulus INTEGER, -- n + /// publicExponent INTEGER -- e + /// } + RSAPublicKey deserializeRSAPublicKeyPKCS1(String keyStr) { + ASN1Sequence asn1sequence = ASN1Parser(base64.decode(keyStr)).nextObject() as ASN1Sequence; + BigInt modulus = (asn1sequence.elements[0] as ASN1Integer).valueAsBigInteger; + BigInt exponent = (asn1sequence.elements[1] as ASN1Integer).valueAsBigInteger; + + return RSAPublicKey(modulus, exponent); + } + + /// Convert an RSA-Public-Key to a DER structure as a BASE64 encoded String. + /// According to the PKCS#1 format: + /// + /// RSAPublicKey ::= SEQUENCE { + /// modulus INTEGER, -- n + /// publicExponent INTEGER -- e + /// } + String serializeRSAPublicKeyPKCS1(RSAPublicKey publicKey) { + ASN1Sequence s = ASN1Sequence() + ..add(ASN1Integer(publicKey.modulus!)) + ..add(ASN1Integer(publicKey.exponent!)); + + return base64.encode(s.encodedBytes); + } + + /// Extract RSA-Public-Keys from DER structure that is a BASE64 encoded Strings. + /// According to the PKCS#8 format: + /// + /// PublicKeyInfo ::= SEQUENCE { + /// algorithm AlgorithmIdentifier, + /// PublicKey BIT STRING + /// } + /// + /// AlgorithmIdentifier ::= SEQUENCE { + /// algorithm OBJECT IDENTIFIER, + /// parameters ANY DEFINED BY algorithm OPTIONAL + /// } + RSAPublicKey deserializeRSAPublicKeyPKCS8(String keyStr) { + var baseSequence = ASN1Parser(base64.decode(keyStr)).nextObject() as ASN1Sequence; + + var encodedAlgorithm = baseSequence.elements[0]; + + var algorithm = ASN1Parser(encodedAlgorithm.contentBytes()).nextObject() as ASN1ObjectIdentifier; + + if (algorithm.identifier != '1.2.840.113549.1.1.1') { + throw ArgumentError.value( + algorithm.identifier, + 'algorithm.identifier', + 'Identifier of algorgorithm does not math identifier of RSA ' + '(1.2.840.113549.1.1.1).'); + } + + var encodedKey = baseSequence.elements[1]; + + var asn1sequence = ASN1Parser(encodedKey.contentBytes()).nextObject() as ASN1Sequence; + + BigInt modulus = (asn1sequence.elements[0] as ASN1Integer).valueAsBigInteger; + BigInt exponent = (asn1sequence.elements[1] as ASN1Integer).valueAsBigInteger; + + return RSAPublicKey(modulus, exponent); + } + + /// Convert an RSA-Public-Key to a DER structure as a BASE64 encoded String. + /// According to the PKCS#8 format: + /// + /// PublicKeyInfo ::= SEQUENCE { + /// algorithm AlgorithmIdentifier, + /// PublicKey BIT STRING + /// } + /// + /// AlgorithmIdentifier ::= SEQUENCE { + /// algorithm OBJECT IDENTIFIER, + /// parameters ANY DEFINED BY algorithm OPTIONAL + /// } + String serializeRSAPublicKeyPKCS8(RSAPublicKey key) { + ASN1ObjectIdentifier.registerFrequentNames(); + ASN1Sequence algorithm = ASN1Sequence() + ..add(ASN1ObjectIdentifier.fromName('rsaEncryption')) + ..add(ASN1Null()); + + var keySequence = ASN1Sequence() + ..add(ASN1Integer(key.modulus!)) + ..add(ASN1Integer(key.exponent!)); + + var publicKey = ASN1BitString(keySequence.encodedBytes); + + var asn1sequence = ASN1Sequence() + ..add(algorithm) + ..add(publicKey); + return base64.encode(asn1sequence.encodedBytes); + } + + /// Convert an RSA-Private-Key to a DER structure as a BASE64 encoded String. + /// According to the PKCS#1 format: + /// + /// RSAPrivateKey ::= SEQUENCE { + /// version Version, + /// modulus INTEGER, -- n + /// publicExponent INTEGER, -- e + /// privateExponent INTEGER, -- d + /// prime1 INTEGER, -- p + /// prime2 INTEGER, -- q + /// exponent1 INTEGER, -- d mod (p-1) + /// exponent2 INTEGER, -- d mod (q-1) + /// coefficient INTEGER, -- (inverse of q) mod p + /// otherPrimeInfos OtherPrimeInfos OPTIONAL + /// } + /// + /// Version ::= INTEGER { two-prime(0), multi(1) } + /// (CONSTRAINED BY {-- version must be multi if otherPrimeInfos present --}) + String serializeRSAPrivateKeyPKCS1(RSAPrivateKey key) { + ASN1Sequence s = ASN1Sequence() + ..add(ASN1Integer.fromInt(0)) // version + ..add(ASN1Integer(key.modulus!)) // modulus + ..add(ASN1Integer(key.exponent!)) // e + ..add(ASN1Integer(key.privateExponent!)) // d + ..add(ASN1Integer(key.p!)) // p + ..add(ASN1Integer(key.q!)) // q + ..add(ASN1Integer(key.privateExponent! % (key.p! - BigInt.one))) // d mod (p-1) + ..add(ASN1Integer(key.privateExponent! % (key.q! - BigInt.one))) // d mod (q-1) + ..add(ASN1Integer(key.q!.modInverse(key.p!))); // q^(-1) mod p + + return base64.encode(s.encodedBytes); + } + + /// Extract RSA-Private-Keys from DER structure that is a BASE64 encoded Strings. + /// According to the PKCS#1 format: + /// + /// RSAPrivateKey ::= SEQUENCE { + /// version Version, + /// modulus INTEGER, -- n + /// publicExponent INTEGER, -- e + /// privateExponent INTEGER, -- d + /// prime1 INTEGER, -- p + /// prime2 INTEGER, -- q + /// exponent1 INTEGER, -- d mod (p-1) + /// exponent2 INTEGER, -- d mod (q-1) + /// coefficient INTEGER, -- (inverse of q) mod p + /// otherPrimeInfos OtherPrimeInfos OPTIONAL + /// } + /// + /// Version ::= INTEGER { two-prime(0), multi(1) } + /// (CONSTRAINED BY {-- version must be multi if otherPrimeInfos present --}) + RSAPrivateKey deserializeRSAPrivateKeyPKCS1(String keyStr) { + ASN1Sequence asn1sequence = ASN1Parser(base64.decode(keyStr)).nextObject() as ASN1Sequence; + BigInt modulus = (asn1sequence.elements[1] as ASN1Integer).valueAsBigInteger; + BigInt exponent = (asn1sequence.elements[2] as ASN1Integer).valueAsBigInteger; + BigInt p = (asn1sequence.elements[4] as ASN1Integer).valueAsBigInteger; + BigInt q = (asn1sequence.elements[5] as ASN1Integer).valueAsBigInteger; + + return RSAPrivateKey(modulus, exponent, p, q); + } + + /// signedMessage is what was allegedly signed, signature gets validated + bool verifyRSASignature(RSAPublicKey publicKey, Uint8List signedMessage, Uint8List signature) { + RSASigner signer = Signer(SIGNING_ALGORITHM) as RSASigner; // Get algorithm from registry + signer.init(false, PublicKeyParameter(publicKey)); // false to validate + + bool isVerified = false; + try { + isVerified = signer.verifySignature(signedMessage, RSASignature(signature)); + } on ArgumentError catch (e, s) { + Logger.warning('Verifying signature failed due to ${e.name}', name: 'crypto_utils.dart#verifyRSASignature', error: e, stackTrace: s); + } + + return isVerified; + } + + /// Tries to sign the [message] with the private key of the [token]. If the token is a + /// legacy token (enrolled prior to v3), the Legacy plugin will be used for that operation. + /// If an error occurs during the operation of the legacy plugin, a dialog will be shown + /// if a [context] is provided, telling the users that it might be better to enroll a new + /// push token so that the app can directly access the private key. + /// Returns the signature on success and null on failure. + Future trySignWithToken(PushToken token, String message) async { + String? signature; + if (token.privateTokenKey == null) { + // It is a legacy token so the operation could cause an exception + try { + signature = await const LegacyUtils().sign(token.serial, message); + } catch (error, stackTrace) { + Logger.error("Error", + error: "An error occured while using the legacy token ${token.label}. " + "The token was enrolled in a old version of this app, which may cause trouble" + " using it. It is suggested to enroll a new push token if the problems persist!", + name: 'crypto_utils.dart#trySignWithToken', + stackTrace: stackTrace); + return null; + } + } else { + signature = createBase32Signature(token.rsaPrivateTokenKey!, utf8.encode(message) as Uint8List); + } + + return signature; + } + + Future> generateRSAKeyPair() async { + Logger.info('Start generating RSA key pair', name: 'crypto_utils.dart#generateRSAKeyPair'); + AsymmetricKeyPair keyPair = await compute(_generateRSAKeyPairIsolate, 4096); + Logger.info('Finished generating RSA key pair', name: 'crypto_utils.dart#generateRSAKeyPair'); + return keyPair; + } + + /// Computationally costly method to be run in an isolate. + AsymmetricKeyPair _generateRSAKeyPairIsolate(int bitLength) { + final keyGen = RSAKeyGenerator()..init(ParametersWithRandom(RSAKeyGeneratorParameters(BigInt.parse('65537'), bitLength, 64), secureRandom())); + + final pair = keyGen.generateKeyPair(); + + return AsymmetricKeyPair(pair.publicKey as RSAPublicKey, pair.privateKey as RSAPrivateKey); + } + + String createBase32Signature(RSAPrivateKey privateKey, Uint8List dataToSign) { + return base32.encode(createRSASignature(privateKey, dataToSign)); + } + + Uint8List createRSASignature(RSAPrivateKey privateKey, Uint8List dataToSign) { + RSASigner signer = Signer(SIGNING_ALGORITHM) as RSASigner; // Get algorithm from registry + signer.init(true, PrivateKeyParameter(privateKey)); // true to sign + + return signer.generateSignature(dataToSign).bytes; + } +} diff --git a/lib/utils/storage_utils.dart b/lib/utils/storage_utils.dart deleted file mode 100644 index c27df4aa9..000000000 --- a/lib/utils/storage_utils.dart +++ /dev/null @@ -1,154 +0,0 @@ -// ignore_for_file: constant_identifier_names - -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'dart:convert'; - -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:mutex/mutex.dart'; -import 'package:privacyidea_authenticator/model/tokens/token.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; - -// TODO How to test the behavior of this class? -class StorageUtil { - // Use this to lock critical sections of code. - static final Mutex _m = Mutex(); - - /// Function [f] is executed, protected by Mutex [_m]. - /// That means, that calls of this method will always be executed serial. - static protect(Function f) => _m.protect(f as Future Function()); - - static const FlutterSecureStorage _storage = FlutterSecureStorage(); - - static const String _GLOBAL_PREFIX = 'app_v3_'; - - // ########################################################################### - // TOKENS - // ########################################################################### - - /// Saves [token] securely on the device, if [token] already exists - /// in the storage the existing value is overwritten. - static Future saveOrReplaceToken(Token token) async { - await _storage.write(key: _GLOBAL_PREFIX + token.id, value: jsonEncode(token)); - Logger.info('Token saved: ${token.id} to secure storage'); - } - - static Future loadToken(String id) async => (await loadAllTokens()).firstWhereOrNull((t) => t.id == id); - - /// Returns a list of all tokens that are saved in the secure storage of - /// this device. - /// If [loadLegacy] is set to true, will attempt to load old android and ios tokens. - static Future> loadAllTokens() async { - Map keyValueMap = await _storage.readAll(); - - List tokenList = []; - - for (var i = 0; i < keyValueMap.length; i++) { - final value = keyValueMap.values.elementAt(i); - final key = keyValueMap.keys.elementAt(i); - // for (String value in keyValueMap.values) { - - Map? serializedToken; - - try { - serializedToken = jsonDecode(value); - } on FormatException catch (e, s) { - if (key == _CURRENT_APP_TOKEN_KEY || key == _NEW_APP_TOKEN_KEY) { - continue; - } - Logger.warning( - 'Could not deserialize token from secure storage. Value: $value, key: $key', - name: 'storage_utils.dart#loadAllTokens', - error: e, - stackTrace: s, - ); - // Skip everything that does not fit a serialized token - continue; - } - - if (serializedToken == null || !serializedToken.containsKey('type')) { - Logger.warning( - 'Could not deserialize token from secure storage. Value: $value\nserializedToken = $serializedToken\ncontainsKey(type) = ${serializedToken?.containsKey('type')} ', - name: 'storage_utils.dart#loadAllTokens'); - // Skip everything that fits for deserialization but is not a token - continue; - } - - // TODO token.version might be deprecated, is there a reason to use it? - // TODO when the token version (token.version) changed handle this here. - - // TODO Is this still needed? Can a json annotation be used instead to - // define default values? - // Handle new fields here - serializedToken['issuer'] ??= ''; - serializedToken['label'] ??= ''; - - tokenList.add(Token.fromJson(serializedToken)); - } - - Logger.info('Loaded ${tokenList.length} tokens from secure storage'); - return tokenList; - } - - /// Deletes the saved json of [token] from the secure storage. - static Future deleteToken(Token token) async { - _storage.delete(key: _GLOBAL_PREFIX + token.id); - Logger.info('Token deleted: ${token.id} from secure storage'); - } - - // ########################################################################### - // FIREBASE CONFIG - // ########################################################################### - - static const _CURRENT_APP_TOKEN_KEY = '${_GLOBAL_PREFIX}CURRENT_APP_TOKEN'; - - static Future setCurrentFirebaseToken(String str) async => _storage.write(key: _CURRENT_APP_TOKEN_KEY, value: str); - - static Future getCurrentFirebaseToken() async => _storage.read(key: _CURRENT_APP_TOKEN_KEY); - - static const _NEW_APP_TOKEN_KEY = '${_GLOBAL_PREFIX}NEW_APP_TOKEN'; - - // This is used for checking if the token was updated. - static Future setNewFirebaseToken(String str) async => _storage.write(key: _NEW_APP_TOKEN_KEY, value: str); - - static Future getNewFirebaseToken() async => _storage.read(key: _NEW_APP_TOKEN_KEY); - -// ########################################################################### -// Update information -// ########################################################################### - - static const _KEY_VERSION = '${_GLOBAL_PREFIX}_app_version'; - - static Future getCurrentVersion() async { - return await _storage.read(key: _KEY_VERSION); - } - - static Future setCurrentVersion(String version) async { - await _storage.write(key: _KEY_VERSION, value: version); - } - - // ######################################################################### - // Misc - // ######################################################################### - - static Future deleteEverything() async => _storage.deleteAll(); -} diff --git a/lib/utils/supported_versions.dart b/lib/utils/supported_versions.dart new file mode 100644 index 000000000..040ed6501 --- /dev/null +++ b/lib/utils/supported_versions.dart @@ -0,0 +1,2 @@ +// The highest version of the pipush Tokentype that this client supports. +const maxPushTokenVersion = 1; diff --git a/lib/utils/themes.dart b/lib/utils/themes.dart index 4e59b4714..8b1378917 100644 --- a/lib/utils/themes.dart +++ b/lib/utils/themes.dart @@ -1,185 +1 @@ -/* - privacyIDEA Authenticator - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:privacyidea_authenticator/utils/app_customizer.dart'; - -//TODO Use this when customizing -Color primarySwatch = ApplicationCustomizer.primaryColor; -Color onPrimary = isColorBright(primarySwatch) ? ApplicationCustomizer.themeColorDark : ApplicationCustomizer.themeColorLight; - -var lightThemeData = ThemeData( - scaffoldBackgroundColor: ApplicationCustomizer.backgroundColorLight, - brightness: Brightness.light, - primaryColorLight: primarySwatch, - primaryColorDark: primarySwatch, - cardColor: ApplicationCustomizer.backgroundColorLight, - appBarTheme: const AppBarTheme().copyWith( - backgroundColor: ApplicationCustomizer.backgroundColorLight, - shadowColor: ApplicationCustomizer.themeColorDark, - elevation: 0, - ), - floatingActionButtonTheme: const FloatingActionButtonThemeData(elevation: 0), - navigationBarTheme: const NavigationBarThemeData().copyWith( - backgroundColor: ApplicationCustomizer.themeColorLight, - shadowColor: ApplicationCustomizer.themeColorDark, - elevation: 6, - ), - listTileTheme: ListTileThemeData( - tileColor: ApplicationCustomizer.backgroundColorLight, - titleTextStyle: TextStyle(color: primarySwatch), - subtitleTextStyle: const TextStyle(color: ApplicationCustomizer.tileSubtitleColorLight), - iconColor: ApplicationCustomizer.tileIconColorLight, - ), - colorScheme: ColorScheme.light( - primary: primarySwatch, - secondary: primarySwatch, - onPrimary: onPrimary, - onSecondary: onPrimary, - errorContainer: ApplicationCustomizer.deleteColorLight, - ), - iconTheme: const IconThemeData(color: Colors.black), - checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - ), - radioTheme: RadioThemeData( - fillColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - ), - switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - trackColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - ), -); - -var darkThemeData = ThemeData( - scaffoldBackgroundColor: ApplicationCustomizer.backgroundColorDark, - brightness: Brightness.dark, - primaryColorLight: primarySwatch, - primaryColorDark: primarySwatch, - cardColor: ApplicationCustomizer.backgroundColorDark, - appBarTheme: const AppBarTheme().copyWith( - backgroundColor: ApplicationCustomizer.backgroundColorDark, - shadowColor: ApplicationCustomizer.themeColorLight, - elevation: 0, - ), - floatingActionButtonTheme: const FloatingActionButtonThemeData(elevation: 0), - navigationBarTheme: const NavigationBarThemeData().copyWith( - backgroundColor: ApplicationCustomizer.themeColorDark, - shadowColor: ApplicationCustomizer.themeColorLight, - elevation: 6, - ), - listTileTheme: ListTileThemeData( - tileColor: ApplicationCustomizer.backgroundColorDark, - titleTextStyle: TextStyle(color: primarySwatch), - subtitleTextStyle: const TextStyle(color: ApplicationCustomizer.tileSubtitleColorDark), - iconColor: ApplicationCustomizer.tileIconColorDark, - ), - colorScheme: ColorScheme.dark( - primary: primarySwatch, - secondary: primarySwatch, - onPrimary: onPrimary, - onSecondary: onPrimary, - errorContainer: ApplicationCustomizer.deleteColorDark, - ), - iconTheme: const IconThemeData(color: Colors.white), - checkboxTheme: CheckboxThemeData( - fillColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - ), - radioTheme: RadioThemeData( - fillColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - ), - switchTheme: SwitchThemeData( - thumbColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - trackColor: MaterialStateProperty.resolveWith((Set states) { - if (states.contains(MaterialState.disabled)) { - return null; - } - if (states.contains(MaterialState.selected)) { - return primarySwatch; - } - return null; - }), - ), -); - -/// Calculate HSP and check if the primary color is bright or dark -/// brightness = sqrt( .299 R^2 + .587 G^2 + .114 B^2 ) -/// c.f., http://alienryderflex.com/hsp.html -bool isColorBright(Color color) { - return math.sqrt(0.299 * math.pow(color.red, 2) + 0.587 * math.pow(color.green, 2) + 0.114 * math.pow(color.blue, 2)) > 150; -} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 85f166ba9..61b81e27a 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,5 +1,3 @@ -// ignore_for_file: library_prefixes - /* privacyIDEA Authenticator @@ -21,15 +19,13 @@ */ import 'dart:convert'; -import 'dart:core'; -import 'dart:typed_data'; +import 'dart:io'; -import 'package:base32/base32.dart' as Base32Converter; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:hex/hex.dart' as HexConverter; -import 'package:otp/otp.dart' as OTPLibrary; +import 'package:http/http.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; import 'package:privacyidea_authenticator/utils/logger.dart'; import 'identifiers.dart'; @@ -47,10 +43,13 @@ String insertCharAt(String str, String char, int pos) { /// /// Example: 'ABCD', 1 --> 'A B C D' /// Example: 'ABCD', 2 --> 'AB CD' +/// +/// If [period] is less than 1, the original String is returned. String splitPeriodically(String str, int period) { + if (period < 1) return str; String result = ''; for (int i = 0; i < str.length; i++) { - i % 4 == 0 ? result += ' ${str[i]}' : result += str[i]; + i % period == 0 ? result += ' ${str[i]}' : result += str[i]; } return result.trim(); @@ -71,7 +70,7 @@ Algorithms mapStringToAlgorithm(String algoAsString) { /// That library sadly depends on [dart.ui] and thus cannot be used in tests. /// Therefore, only using this code enables us to use this library ([utils.dart]) /// in tests. -String enumAsString(Object enumEntry) { +String enumAsString(Enum enumEntry) { final String description = enumEntry.toString(); final int indexOfDot = description.indexOf('.'); assert(indexOfDot != -1 && indexOfDot < description.length - 1); @@ -84,82 +83,27 @@ bool equalsIgnoreCase(String s1, String s2) { /// If permission is already given, this function does nothing void checkNotificationPermission() async { + if (kIsWeb || !Platform.isAndroid && !Platform.isIOS) return; var status = await Permission.notification.status; Logger.info('Notification permission status: $status'); // TODO what to do if permanently denied? // Add a dialog before requesting? + if (!status.isPermanentlyDenied) { if (status.isDenied) { - await Permission.notification.request(); + try { + await Permission.notification.request(); + } catch (e) { + await Future.delayed(const Duration(seconds: 5)); + checkNotificationPermission(); + Logger.warning('Error requesting notification permission: $e'); + } } } else { Logger.info('Notification permission is permanently denied!'); } } -// TODO Everything after this line should be in 'crypto_utils.dart, -// but that depends on foundations.dart and that depends on dart.ui, -// which ultimately makes it impossible to run driver tests. -Uint8List decodeSecretToUint8(String secret, Encodings encoding) { - ArgumentError.checkNotNull(secret, 'secret'); - ArgumentError.checkNotNull(encoding, 'encoding'); - - switch (encoding) { - case Encodings.none: - return Uint8List.fromList(utf8.encode(secret)); - case Encodings.hex: - return Uint8List.fromList(HexConverter.HEX.decode(secret)); - case Encodings.base32: - return Uint8List.fromList(Base32Converter.base32.decode(secret)); - default: - throw ArgumentError.value(encoding, 'encoding', 'The encoding is unknown and not supported!'); - } -} - -String encodeSecretAs(Uint8List secret, Encodings encoding) { - ArgumentError.checkNotNull(secret, 'secret'); - ArgumentError.checkNotNull(encoding, 'encoding'); - - switch (encoding) { - case Encodings.none: - return utf8.decode(secret); - case Encodings.hex: - return HexConverter.HEX.encode(secret); - case Encodings.base32: - return Base32Converter.base32.encode(secret); - default: - throw ArgumentError.value(encoding, 'encoding', 'The encoding is unknown and not supported!'); - } -} - -String encodeAsHex(Uint8List secret) { - return encodeSecretAs(secret, Encodings.hex); -} - -bool isValidEncoding(String secret, Encodings encoding) { - try { - decodeSecretToUint8(secret, encoding); - } on Exception catch (_) { - return false; - } - return true; -} - -OTPLibrary.Algorithm mapAlgorithms(Algorithms algorithm) { - ArgumentError.checkNotNull(algorithm, 'algorithmName'); - - switch (algorithm) { - case Algorithms.SHA1: - return OTPLibrary.Algorithm.SHA1; - case Algorithms.SHA256: - return OTPLibrary.Algorithm.SHA256; - case Algorithms.SHA512: - return OTPLibrary.Algorithm.SHA512; - default: - throw ArgumentError.value(algorithm, 'algorithmName', 'This algorithm is unknown and not supported!'); - } -} - String rolloutMsg(PushTokenRollOutState rolloutState, BuildContext context) => switch (rolloutState) { PushTokenRollOutState.rolloutNotStarted => AppLocalizations.of(context)!.rollingOut, PushTokenRollOutState.generatingRSAKeyPair => AppLocalizations.of(context)!.generatingRSAKeyPair, @@ -170,3 +114,81 @@ String rolloutMsg(PushTokenRollOutState rolloutState, BuildContext context) => s PushTokenRollOutState.parsingResponseFailed => AppLocalizations.of(context)!.parsingResponseFailed, PushTokenRollOutState.rolloutComplete => AppLocalizations.of(context)!.rolloutCompleted, }; + +String? getErrorMessageFromResponse(Response response) { + String body = response.body; + String? errorMessage; + try { + final json = jsonDecode(body) as Map; + errorMessage = json['result']?['error']?['message'] as String?; + } catch (e) { + errorMessage = null; + } + if (errorMessage == null) { + final statusMessage = _statusMessageFromCode[response.statusCode]; + if (statusMessage != null) { + errorMessage = '${response.statusCode}: $statusMessage'; + } else { + errorMessage = 'Status Code: ${response.statusCode}'; + } + } + return errorMessage; +} + +Map _statusMessageFromCode = { + 100: "Continue", + 101: "Switching Protocols", + 102: "Processing", + 200: "OK", + 201: "Created", + 202: "Accepted", + 203: "Non Authoritative Information", + 204: "No Content", + 205: "Reset Content", + 206: "Partial Content", + 207: "Multi-Status", + 300: "Multiple Choices", + 301: "Moved Permanently", + 302: "Moved Temporarily", + 303: "See Other", + 304: "Not Modified", + 305: "Use Proxy", + 307: "Temporary Redirect", + 308: "Permanent Redirect", + 400: "Bad Request", + 401: "Unauthorized", + 402: "Payment Required", + 403: "Forbidden", + 404: "Not Found", + 405: "Method Not Allowed", + 406: "Not Acceptable", + 407: "Proxy Authentication Required", + 408: "Request Timeout", + 409: "Conflict", + 410: "Gone", + 411: "Length Required", + 412: "Precondition Failed", + 413: "Request Entity Too Large", + 414: "Request-URI Too Long", + 415: "Unsupported Media Type", + 416: "Requested Range Not Satisfiable", + 417: "Expectation Failed", + 418: "I'm a teapot", + 419: "Insufficient Space on Resource", + 420: "Method Failure", + 422: "Unprocessable Entity", + 423: "Locked", + 424: "Failed Dependency", + 428: "Precondition Required", + 429: "Too Many Requests", + 431: "Request Header Fields Too Large", + 451: "Unavailable For Legal Reasons", + 500: "Internal Server Error", + 501: "Not Implemented", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + 505: "HTTP Version Not Supported", + 507: "Insufficient Storage", + 511: "Network Authentication Required" +}; diff --git a/lib/utils/view_utils.dart b/lib/utils/view_utils.dart index d9ff98dbe..d193af9f9 100644 --- a/lib/utils/view_utils.dart +++ b/lib/utils/view_utils.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; + import 'customizations.dart'; import 'logger.dart'; -/// Shows a message to the user for a given `Duration`. +/// Shows a snackbar message to the user for a given `Duration`. void showMessage({ required String message, Duration duration = const Duration(seconds: 5), @@ -18,6 +19,7 @@ void showMessage({ Future showAsyncDialog({ required WidgetBuilder builder, + bool barrierDismissible = true, }) { if (globalNavigatorKey.currentContext == null) { Logger.warning('globalNavigatorKey.currentContext is null'); @@ -26,5 +28,7 @@ Future showAsyncDialog({ return showDialog( context: globalNavigatorKey.currentContext!, builder: builder, + useRootNavigator: false, + barrierDismissible: barrierDismissible, ); } diff --git a/lib/views/add_token_manually_view/add_token_manually_view.dart b/lib/views/add_token_manually_view/add_token_manually_view.dart index 805790bd5..24efc2f6f 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; +import '../../l10n/app_localizations.dart'; import '../../model/tokens/day_password_token.dart'; import '../../model/tokens/hotp_token.dart'; import '../../model/tokens/otp_token.dart'; import '../../model/tokens/totp_token.dart'; +import '../../utils/crypto_utils.dart'; import '../../utils/identifiers.dart'; import '../../utils/logger.dart'; import '../../utils/riverpod_providers.dart'; -import '../../utils/utils.dart'; import 'add_token_manually_view_widgets/labeled_dropdown_button.dart'; class AddTokenManuallyView extends ConsumerStatefulWidget { @@ -123,7 +123,7 @@ class _AddTokenManuallyViewState extends ConsumerState { Visibility( // the period is only used by TOTP tokens visible: _typeNotifier.value == TokenTypes.TOTP, - child: LabeledDropdownButton( + child: LabeledDropdownButton( label: AppLocalizations.of(context)!.period, values: AddTokenManuallyView.allowedPeriodsTOTP, valueNotifier: _periodNotifier, @@ -133,7 +133,7 @@ class _AddTokenManuallyViewState extends ConsumerState { Visibility( // the period is only used by DayPassword tokens visible: _typeNotifier.value == TokenTypes.DAYPASSWORD, - child: LabeledDropdownButton( + child: LabeledDropdownButton( label: AppLocalizations.of(context)!.period, values: AddTokenManuallyView.allowedPeriodsDayPassword, valueNotifier: _periodDayPasswordNotifier, @@ -152,7 +152,7 @@ class _AddTokenManuallyViewState extends ConsumerState { onPressed: () { final token = _buildTokenIfValid(context: context); if (token != null) { - ref.read(tokenProvider.notifier).addToken(token); + ref.read(tokenProvider.notifier).addOrReplaceToken(token); Navigator.pop(context); } }, diff --git a/lib/views/add_token_manually_view/add_token_manually_view_widgets/labeled_dropdown_button.dart b/lib/views/add_token_manually_view/add_token_manually_view_widgets/labeled_dropdown_button.dart index 5ef49665b..855706fea 100644 --- a/lib/views/add_token_manually_view/add_token_manually_view_widgets/labeled_dropdown_button.dart +++ b/lib/views/add_token_manually_view/add_token_manually_view_widgets/labeled_dropdown_button.dart @@ -47,7 +47,7 @@ class _LabeledDropdownButtonState extends State> { return DropdownMenuItem( value: value, child: Text( - '${value is String || value is int || value is double ? value : enumAsString(value!)}' + '${value is Enum ? enumAsString(value) : value}' '${widget.postFix}', style: Theme.of(context).textTheme.titleMedium, overflow: TextOverflow.fade, diff --git a/lib/views/license_view/license_view.dart b/lib/views/license_view/license_view.dart new file mode 100644 index 000000000..909a97359 --- /dev/null +++ b/lib/views/license_view/license_view.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class LicenseView extends StatelessWidget { + static const String routeName = '/license'; + final String appName; + final Widget appImage; + final String websiteLink; + + const LicenseView({required this.appName, required this.websiteLink, required this.appImage, super.key}); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, platformInfo) => LicensePage( + applicationName: appName, + applicationIcon: Padding( + padding: const EdgeInsets.all(32), + child: appImage, + ), + applicationLegalese: websiteLink, + applicationVersion: platformInfo.data == null ? '' : '${platformInfo.data?.version}+${platformInfo.data?.buildNumber}', + ), + ); +} diff --git a/lib/views/main_view/main_view.dart b/lib/views/main_view/main_view.dart index 20b2d638c..48d982956 100644 --- a/lib/views/main_view/main_view.dart +++ b/lib/views/main_view/main_view.dart @@ -3,20 +3,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutterlifecyclehooks/flutterlifecyclehooks.dart'; import '../../model/states/app_state.dart'; -import '../../utils/app_customizer.dart'; import '../../utils/logger.dart'; import '../../utils/riverpod_providers.dart'; -import 'main_view_widgets/main_view_navigation_buttons.dart'; +import 'main_view_widgets/main_view_navigation_bar.dart'; import 'main_view_widgets/main_view_tokens_list.dart'; -import 'main_view_widgets/no_token_screen.dart'; + +export 'package:privacyidea_authenticator/views/main_view/main_view.dart'; class MainView extends ConsumerStatefulWidget { static const routeName = '/mainView'; - final String _title; - const MainView({Key? key, required String title}) - : _title = title, - super(key: key); + final Widget appIcon; + final String appName; + + const MainView({required this.appIcon, required this.appName, super.key}); @override ConsumerState createState() => _MainViewState(); @@ -39,27 +39,26 @@ class _MainViewState extends ConsumerState with LifecycleMixin { } @override - Widget build(BuildContext context) { - final tokenList = ref.watch(tokenProvider).tokens; - final folderList = ref.watch(tokenFolderProvider).folders; - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: AppBar( - title: Text( - widget._title, - overflow: TextOverflow.ellipsis, - // maxLines: 2 only works like this. - maxLines: 2, // Title can be shown on small screens too. + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Text( + widget.appName, + overflow: TextOverflow.ellipsis, + // maxLines: 2 only works like this. + maxLines: 2, // Title can be shown on small screens too. + ), + leading: Padding( + padding: const EdgeInsets.all(4), + child: widget.appIcon, + ), ), - leading: Image.asset(ApplicationCustomizer.appIcon), - ), - body: Stack( - clipBehavior: Clip.antiAliasWithSaveLayer, - children: [ - tokenList.isEmpty && folderList.isEmpty ? const NoTokenScreen() : MainViewTokensList(tokenList, nestedScrollViewKey: globalKey), - const MainViewNavigationButtions(), - ], - ), - ); - } + body: Stack( + clipBehavior: Clip.antiAliasWithSaveLayer, + children: [ + MainViewTokensList(nestedScrollViewKey: globalKey), + const MainViewNavigationBar(), + ], + ), + ); } diff --git a/lib/views/main_view/main_view_widgets/app_bar_item.dart b/lib/views/main_view/main_view_widgets/app_bar_item.dart index 9478c18aa..f50bb94d2 100644 --- a/lib/views/main_view/main_view_widgets/app_bar_item.dart +++ b/lib/views/main_view/main_view_widgets/app_bar_item.dart @@ -1,20 +1,23 @@ import 'package:flutter/material.dart'; class AppBarItem extends StatelessWidget { - const AppBarItem({Key? key, required this.onPressed, required this.icon}) : super(key: key); + const AppBarItem({super.key, required this.onPressed, required this.icon}); final VoidCallback onPressed; - final IconData icon; + final Icon icon; @override Widget build(BuildContext context) { return IconButton( - padding: const EdgeInsets.all(0), - splashRadius: 0.1, - onPressed: onPressed, - icon: Icon( - icon, - size: 24, - )); + padding: const EdgeInsets.all(0), + splashRadius: 0.1, + onPressed: onPressed, + color: Theme.of(context).navigationBarTheme.iconTheme?.resolve({})?.color, + icon: SizedBox( + height: 24, + width: 24, + child: icon, + ), + ); } } diff --git a/lib/views/main_view/main_view_widgets/custom_paint_navigation_bar.dart b/lib/views/main_view/main_view_widgets/custom_paint_navigation_bar.dart index c65285fa8..fc2da2f79 100644 --- a/lib/views/main_view/main_view_widgets/custom_paint_navigation_bar.dart +++ b/lib/views/main_view/main_view_widgets/custom_paint_navigation_bar.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:flutter/material.dart'; @@ -26,6 +27,7 @@ class CustomPaintNavigationBar extends CustomPainter { Theme.of(buildContext).navigationBarTheme.backgroundColor ?? Theme.of(buildContext).appBarTheme.backgroundColor ?? Theme.of(buildContext).primaryColor; final Color shadowColor = Theme.of(buildContext).navigationBarTheme.shadowColor ?? Theme.of(buildContext).appBarTheme.shadowColor ?? Theme.of(buildContext).shadowColor; + final elevation = Theme.of(buildContext).navigationBarTheme.elevation ?? 3; final double radiusPx = min(40, size.height * 0.8); Paint paint = Paint() ..color = appBarColor @@ -40,7 +42,13 @@ class CustomPaintNavigationBar extends CustomPainter { ..lineTo(size.width * 1.0, size.height * 1.0) // point 7 ..lineTo(size.width * 0.0, size.height * 1.0) // point 8 ..close(); // point 1 - canvas.drawShadow(path, shadowColor, 10, false); + + // TODO: remove this if statement when the shadow bug is fixed on iOS + if (Platform.isIOS == false) { + canvas.translate(0, -elevation); + canvas.drawShadow(path, shadowColor, elevation, false); + canvas.translate(0, elevation); + } canvas.drawPath(path, paint); } diff --git a/lib/views/main_view/main_view_widgets/drag_target_divider.dart b/lib/views/main_view/main_view_widgets/drag_target_divider.dart index 43e6b8c8e..173f69817 100644 --- a/lib/views/main_view/main_view_widgets/drag_target_divider.dart +++ b/lib/views/main_view/main_view_widgets/drag_target_divider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,12 +13,14 @@ import '../../../widgets/drag_item_scroller.dart'; class DragTargetDivider extends ConsumerStatefulWidget { final TokenFolder? dependingFolder; final SortableMixin? nextSortable; + final bool ignoreFolderId; final bool isLastDivider; const DragTargetDivider({ super.key, required this.dependingFolder, required this.nextSortable, + this.ignoreFolderId = false, this.isLastDivider = false, }); @@ -62,7 +65,9 @@ class _DragTargetDividerState extends ConsumerState a.compareTo(b)); final oldIndex = allSortables.indexOf(dragedSortable); if (oldIndex == -1) return; // If the draged item is not in the list we dont need to do anything @@ -81,7 +86,7 @@ class _DragTargetDividerState extends ConsumerState previousFolderId); } @@ -105,7 +110,8 @@ class _DragTargetDividerState extends ConsumerState().toList()); + globalRef?.read(tokenProvider.notifier).updateTokens( + allTokens, (p0) => p0.copyWith(sortIndex: modifiedSortables.whereType().firstWhereOrNull((updated) => updated.id == p0.id)?.sortIndex)); globalRef?.read(tokenFolderProvider.notifier).updateFolders(modifiedSortables.whereType().toList()); }, builder: (context, accepted, rejected) { diff --git a/lib/views/main_view/add_token_folder_dialog.dart b/lib/views/main_view/main_view_widgets/folder_widgets/add_token_folder_dialog.dart similarity index 90% rename from lib/views/main_view/add_token_folder_dialog.dart rename to lib/views/main_view/main_view_widgets/folder_widgets/add_token_folder_dialog.dart index f2e13f69f..0ec5d97a0 100644 --- a/lib/views/main_view/add_token_folder_dialog.dart +++ b/lib/views/main_view/main_view_widgets/folder_widgets/add_token_folder_dialog.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../utils/riverpod_providers.dart'; -import '../../widgets/default_dialog.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../utils/riverpod_providers.dart'; +import '../../../../widgets/default_dialog.dart'; class AddTokenFolderDialog extends ConsumerWidget { final textController = TextEditingController(); diff --git a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/delete_token_folder_action.dart b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/delete_token_folder_action.dart index e5c632e8d..d37f7242a 100644 --- a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/delete_token_folder_action.dart +++ b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/delete_token_folder_action.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/token_folder.dart'; import '../../../../../utils/app_customizer.dart'; import '../../../../../utils/customizations.dart'; @@ -16,8 +16,8 @@ class DeleteTokenFolderAction extends StatelessWidget { @override Widget build(BuildContext context) { return CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.deleteColorLight : ApplicationCustomizer.deleteColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.deleteColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (folder.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.unlock) == false) return; _showDialog(); @@ -38,6 +38,7 @@ class DeleteTokenFolderAction extends StatelessWidget { } void _showDialog() => showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) { return DefaultDialog( @@ -59,10 +60,7 @@ class DeleteTokenFolderAction extends StatelessWidget { onPressed: () { final tokens = globalRef?.read(tokenProvider).tokensInFolder(folder); if (tokens == null) return; - for (var i = 0; i < tokens.length; i++) { - tokens[i] = tokens[i].copyWith(folderId: () => null); - } - globalRef?.read(tokenProvider.notifier).updateTokens(tokens); + globalRef?.read(tokenProvider.notifier).updateTokens(tokens, (p0) => p0.copyWith(folderId: () => null)); globalRef?.read(tokenFolderProvider.notifier).removeFolder(folder); Navigator.of(context).pop(); }, diff --git a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/lock_token_folder_action.dart b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/lock_token_folder_action.dart index bce992f2a..3255cfbc5 100644 --- a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/lock_token_folder_action.dart +++ b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/lock_token_folder_action.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/token_folder.dart'; import '../../../../../utils/app_customizer.dart'; import '../../../../../utils/lock_auth.dart'; @@ -14,8 +14,8 @@ class LockTokenFolderAction extends StatelessWidget { @override Widget build(BuildContext context) { return CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.lockColorLight : ApplicationCustomizer.lockColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.lockColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.unlock) == false) return; globalRef?.read(tokenFolderProvider.notifier).updateFolder(folder.copyWith(isLocked: !folder.isLocked)); diff --git a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/rename_token_folder_action.dart b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/rename_token_folder_action.dart index f9894a88a..50bf7f3d1 100644 --- a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/rename_token_folder_action.dart +++ b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_actions.dart/rename_token_folder_action.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/token_folder.dart'; import '../../../../../utils/app_customizer.dart'; import '../../../../../utils/customizations.dart'; @@ -12,13 +12,13 @@ import '../../../../../widgets/default_dialog.dart'; class RenameTokenFolderAction extends StatelessWidget { final TokenFolder folder; - const RenameTokenFolderAction({required this.folder, Key? key}) : super(key: key); + const RenameTokenFolderAction({required this.folder, super.key}); @override Widget build(BuildContext context) { return CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.renameColorLight : ApplicationCustomizer.renameColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.editColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (folder.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.unlock) == false) return; _showDialog(); @@ -40,6 +40,7 @@ class RenameTokenFolderAction extends StatelessWidget { void _showDialog() { TextEditingController nameInputController = TextEditingController(text: folder.label); showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) { return DefaultDialog( diff --git a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_expandable.dart b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_expandable.dart index 67f66d2b1..ffa97bcde 100644 --- a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_expandable.dart +++ b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_expandable.dart @@ -3,19 +3,20 @@ import 'dart:async'; import 'package:expandable/expandable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'token_folder_actions.dart/delete_token_folder_action.dart'; -import 'token_folder_actions.dart/lock_token_folder_action.dart'; -import 'token_folder_actions.dart/rename_token_folder_action.dart'; + +import '../../../../l10n/app_localizations.dart'; import '../../../../model/token_folder.dart'; +import '../../../../model/tokens/push_token.dart'; import '../../../../model/tokens/token.dart'; import '../../../../utils/lock_auth.dart'; import '../../../../utils/riverpod_providers.dart'; import '../../../../widgets/custom_trailing.dart'; import '../drag_target_divider.dart'; import '../token_widgets/token_widget_builder.dart'; +import 'token_folder_actions.dart/delete_token_folder_action.dart'; +import 'token_folder_actions.dart/lock_token_folder_action.dart'; +import 'token_folder_actions.dart/rename_token_folder_action.dart'; class TokenFolderExpandable extends ConsumerStatefulWidget { final TokenFolder folder; @@ -60,7 +61,7 @@ class _TokenFolderExpandableState extends ConsumerState w @override ExpandablePanel build(BuildContext context) { - final tokens = ref.watch(tokenProvider).tokensInFolder(widget.folder); + final tokens = ref.watch(tokenProvider).tokensInFolder(widget.folder, exclude: ref.watch(settingsProvider).hidePushTokens ? [PushToken] : []); final draggingSortable = ref.watch(draggingSortableProvider); if (tokens.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { @@ -111,8 +112,11 @@ class _TokenFolderExpandableState extends ConsumerState w }, onLeave: (data) => _expandTimer?.cancel(), onAccept: (data) { - final updatedToken = (data as Token).copyWith(folderId: () => widget.folder.folderId); - ref.read(tokenProvider.notifier).updateToken(updatedToken); + if (data is! Token) return; + ref.read(tokenProvider.notifier).updateToken( + data, + (p0) => p0.copyWith(folderId: () => widget.folder.folderId), + ); }, builder: (context, willAccept, willReject) => Center( child: Container( @@ -140,6 +144,7 @@ class _TokenFolderExpandableState extends ConsumerState w )), const SizedBox(width: 8), Expanded( + flex: 2, child: Text( widget.folder.label, style: Theme.of(context).textTheme.titleLarge, diff --git a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_widget.dart b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_widget.dart index 17cf4c18b..229aa8815 100644 --- a/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_widget.dart +++ b/lib/views/main_view/main_view_widgets/folder_widgets/token_folder_widget.dart @@ -2,16 +2,16 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'token_folder_expandable.dart'; import '../../../../model/token_folder.dart'; import '../../../../utils/riverpod_providers.dart'; import '../../../../utils/text_size.dart'; +import 'token_folder_expandable.dart'; class TokenFolderWidget extends ConsumerWidget { final TokenFolder folder; - const TokenFolderWidget(this.folder, {Key? key}) : super(key: key); + const TokenFolderWidget(this.folder, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/views/main_view/main_view_widgets/main_view_navigation_bar.dart b/lib/views/main_view/main_view_widgets/main_view_navigation_bar.dart new file mode 100644 index 000000000..a6b8f8cf3 --- /dev/null +++ b/lib/views/main_view/main_view_widgets/main_view_navigation_bar.dart @@ -0,0 +1,112 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../add_token_manually_view/add_token_manually_view.dart'; +import '../../settings_view/settings_view.dart'; +import 'app_bar_item.dart'; +import 'custom_paint_navigation_bar.dart'; +import 'folder_widgets/add_token_folder_dialog.dart'; +import 'main_view_navigation_buttons/license_push_view_button.dart'; +import 'main_view_navigation_buttons/qr_scanner_button.dart'; + +class MainViewNavigationBar extends ConsumerWidget { + const MainViewNavigationBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Positioned.fill( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final navWidth = constraints.maxWidth; + final navHeight = constraints.maxHeight * 0.10; + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: navWidth, + height: navHeight, + child: Stack( + children: [ + CustomPaint( + size: Size( + navWidth, + navHeight, + ), + painter: CustomPaintNavigationBar(buildContext: context), + ), + const Center( + heightFactor: 0.6, + child: QrScannerButton(), + ), + Center( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: Center( + child: Padding( + padding: EdgeInsets.only(top: navHeight * 0.2, bottom: navHeight * 0.1), + child: const LicensePushViewButton(), + ), + ), + ), + Expanded( + child: Center( + child: Padding( + padding: EdgeInsets.only(top: navHeight * 0.1, bottom: navHeight * 0.2), + child: AppBarItem( + onPressed: () { + Navigator.pushNamed(context, AddTokenManuallyView.routeName); + }, + icon: const Icon(Icons.add_moderator), + ), + ), + ), + ), + SizedBox(width: min(110, navHeight * 0.8 + 30)), + Expanded( + child: Center( + child: Padding( + padding: EdgeInsets.only(top: navHeight * 0.1, bottom: navHeight * 0.2), + child: AppBarItem( + onPressed: () { + showDialog( + context: context, + builder: (context) => AddTokenFolderDialog(), + useRootNavigator: false, + ); + }, + icon: const Icon(Icons.create_new_folder), + ), + ), + ), + ), + Expanded( + child: Center( + child: Padding( + padding: EdgeInsets.only(top: navHeight * 0.2, bottom: navHeight * 0.1), + child: AppBarItem( + onPressed: () { + Navigator.pushNamed(context, SettingsView.routeName); + }, + icon: const Icon(Icons.settings), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/views/main_view/main_view_widgets/main_view_navigation_buttons.dart b/lib/views/main_view/main_view_widgets/main_view_navigation_buttons.dart deleted file mode 100644 index 723044135..000000000 --- a/lib/views/main_view/main_view_widgets/main_view_navigation_buttons.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import '../../../utils/app_customizer.dart'; -import '../../../utils/riverpod_providers.dart'; -import '../../add_token_manually_view/add_token_manually_view.dart'; -import '../../qr_scanner_view/scanner_view.dart'; -import '../../settings_view/settings_view.dart'; -import '../add_token_folder_dialog.dart'; -import 'app_bar_item.dart'; -import 'custom_paint_navigation_bar.dart'; - -class MainViewNavigationButtions extends StatelessWidget { - const MainViewNavigationButtions({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final navWidth = size.width; - final navHeight = size.height * 0.10; - return Positioned( - bottom: 0, - left: 0, - child: SizedBox( - width: navWidth, - height: navHeight, - child: Stack( - children: [ - CustomPaint( - size: Size( - navWidth, - navHeight, - ), - painter: CustomPaintNavigationBar(buildContext: context), - ), - Center( - heightFactor: 0.6, - child: FloatingActionButton( - onPressed: () { - /// Open the QR-code scanner and call `_handleOtpAuth`, with the scanned code as the argument. - Navigator.pushNamed(context, QRScannerView.routeName).then((qrCode) { - if (qrCode != null) globalRef?.read(tokenProvider.notifier).addTokenFromOtpAuth(otpAuth: qrCode as String, context: context); - }); - }, - tooltip: AppLocalizations.of(context)!.scanQrCode, - child: const Icon(Icons.qr_code_scanner_outlined), - ), - ), - SizedBox( - width: navWidth, - height: navHeight * 0.9, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - AppBarItem( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => LicensePage( - applicationName: ApplicationCustomizer.appName, - applicationIcon: Image.asset( - ApplicationCustomizer.appIcon, - height: size.height * 0.3, - ), - applicationLegalese: ApplicationCustomizer.websiteLink, - applicationVersion: '${globalRef?.read(platformInfoProvider).appVersion}+${globalRef?.read(platformInfoProvider).buildNumber}', - ), - ), - ); - }, - icon: Icons.info_outline, - ), - SizedBox( - height: navHeight * 0.9, - child: AppBarItem( - onPressed: () { - Navigator.pushNamed(context, AddTokenManuallyView.routeName); - }, - icon: Icons.add_moderator, - ), - ), - SizedBox(width: navWidth * 0.2), - SizedBox( - height: navHeight * 0.9, - child: AppBarItem( - onPressed: () { - showDialog(context: context, builder: (context) => AddTokenFolderDialog()); - }, - icon: Icons.create_new_folder, - ), - ), - AppBarItem( - onPressed: () { - Navigator.pushNamed(context, SettingsView.routeName); - }, - icon: Icons.settings), - ], - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/views/main_view/main_view_widgets/main_view_navigation_buttons/license_push_view_button.dart b/lib/views/main_view/main_view_widgets/main_view_navigation_buttons/license_push_view_button.dart new file mode 100644 index 000000000..0ea470378 --- /dev/null +++ b/lib/views/main_view/main_view_widgets/main_view_navigation_buttons/license_push_view_button.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../model/states/settings_state.dart'; +import '../../../../utils/riverpod_providers.dart'; +import '../../../../widgets/pulse_icon.dart'; +import '../../../license_view/license_view.dart'; +import '../../../push_token_view/push_tokens_view.dart'; +import '../app_bar_item.dart'; + +class LicensePushViewButton extends ConsumerWidget { + const LicensePushViewButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(settingsProvider).hidePushTokensState) { + HidePushTokens.notHidden => AppBarItem( + onPressed: () => Navigator.of(context).pushNamed(LicenseView.routeName), + icon: const Icon(Icons.info_outline), + ), + HidePushTokens.isHiddenNotNoticed => PulseIcon( + size: 24, + isPulsing: ref.watch(settingsProvider).hidePushTokensState == HidePushTokens.isHiddenNotNoticed, + child: AppBarItem( + onPressed: () { + ref.read(settingsProvider.notifier).setHidePushTokens(hidePushTokensState: HidePushTokens.isHiddenAndNoticed); + Navigator.pushNamed(context, PushTokensView.routeName); + }, + icon: const Icon(Icons.notifications), + ), + ), + HidePushTokens.isHiddenAndNoticed => AppBarItem( + onPressed: () { + ref.read(settingsProvider.notifier).setHidePushTokens(hidePushTokensState: HidePushTokens.isHiddenAndNoticed); + Navigator.pushNamed(context, PushTokensView.routeName); + }, + icon: const Icon(Icons.notifications), + ), + }; +} diff --git a/lib/views/main_view/main_view_widgets/main_view_navigation_buttons/qr_scanner_button.dart b/lib/views/main_view/main_view_widgets/main_view_navigation_buttons/qr_scanner_button.dart new file mode 100644 index 000000000..485c19db5 --- /dev/null +++ b/lib/views/main_view/main_view_widgets/main_view_navigation_buttons/qr_scanner_button.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../../../l10n/app_localizations.dart'; +import '../../../../utils/riverpod_providers.dart'; +import '../../../../utils/view_utils.dart'; +import '../../../../widgets/default_dialog.dart'; +import '../../../qr_scanner_view/qr_scanner_view.dart'; + +class QrScannerButton extends ConsumerWidget { + const QrScannerButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => FloatingActionButton( + onPressed: () async { + if (await Permission.camera.isPermanentlyDenied) { + showAsyncDialog( + builder: (_) => DefaultDialog( + title: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogTitle), + content: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogPermanentlyDenied), + ), + ); + return; + } + + /// Open the QR-code scanner and call `_handleOtpAuth`, with the scanned code as the argument. + // ignore: use_build_context_synchronously + Navigator.pushNamed(context, QRScannerView.routeName).then((qrCode) { + if (qrCode != null) ref.read(tokenProvider.notifier).addTokenFromOtpAuth(otpAuth: qrCode as String); + }); + }, + tooltip: AppLocalizations.of(context)?.scanQrCode ?? '', + child: const Icon(Icons.qr_code_scanner_outlined), + ); +} diff --git a/lib/views/main_view/main_view_widgets/main_view_tokens_list.dart b/lib/views/main_view/main_view_widgets/main_view_tokens_list.dart index 137b81973..9125dffe2 100644 --- a/lib/views/main_view/main_view_widgets/main_view_tokens_list.dart +++ b/lib/views/main_view/main_view_widgets/main_view_tokens_list.dart @@ -1,27 +1,25 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:privacyidea_authenticator/views/main_view/main_view_widgets/push_request_overlay.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../model/mixins/sortable_mixin.dart'; import '../../../model/token_folder.dart'; import '../../../model/tokens/push_token.dart'; -import '../../../model/tokens/token.dart'; import '../../../utils/push_provider.dart'; import '../../../utils/riverpod_providers.dart'; import '../../../utils/view_utils.dart'; +import '../../../widgets/deactivateable_refresh_indicator.dart'; import '../../../widgets/drag_item_scroller.dart'; -import '../deactivateable_refresh_indicator.dart'; +import '../../../widgets/push_request_overlay.dart'; import 'drag_target_divider.dart'; import 'no_token_screen.dart'; import 'sortable_widget_builder.dart'; class MainViewTokensList extends ConsumerStatefulWidget { - final List tokens; final GlobalKey nestedScrollViewKey; - const MainViewTokensList(this.tokens, {Key? key, required this.nestedScrollViewKey}) : super(key: key); + const MainViewTokensList({super.key, required this.nestedScrollViewKey}); @override ConsumerState createState() => _MainViewTokensListState(); @@ -37,15 +35,18 @@ class _MainViewTokensListState extends ConsumerState { Widget build(BuildContext context) { final tokenFolders = ref.watch(tokenFolderProvider).folders; final tokenState = ref.watch(tokenProvider); - final allowToRefresh = tokenState.tokens.any((token) => token is PushToken); + final allowToRefresh = tokenState.hasPushTokens; final draggingSortable = ref.watch(draggingSortableProvider); - final tokenStateWithNoFolder = tokenState.tokensWithoutFolder(); + bool filterPushTokens = ref.watch(settingsProvider).hidePushTokens && tokenState.hasHOTPTokens; + + final tokenStateWithNoFolder = tokenState.tokensWithoutFolder(exclude: filterPushTokens ? [PushToken] : []); final tokenWithPushRequest = tokenState.tokenWithPushRequest(); List sortables = [...tokenFolders, ...tokenStateWithNoFolder]; return Stack( children: [ + if (sortables.isEmpty) const NoTokenScreen(), DeactivateableRefreshIndicator( allowToRefresh: allowToRefresh, onRefresh: () async { @@ -53,7 +54,7 @@ class _MainViewTokensListState extends ConsumerState { message: AppLocalizations.of(context)!.pollingChallenges, duration: const Duration(seconds: 1), ); - final errorMessage = await PushProvider.pollForChallenges(showMessageForEachToken: true); + final errorMessage = await PushProvider().pollForChallenges(showMessageForEachToken: true); if (errorMessage != null) showMessage(message: errorMessage); }, child: SlidableAutoCloseBehavior( @@ -83,10 +84,7 @@ class _MainViewTokensListState extends ConsumerState { List _buildSortableWidgets(List sortables, SortableMixin? draggingSortable) { List widgets = []; - if (sortables.isEmpty) { - widgets.add(const NoTokenScreen()); - return widgets; - } + if (sortables.isEmpty) return []; sortables.sort((a, b) => a.compareTo(b)); for (var i = 0; i < sortables.length; i++) { final isFirst = i == 0; diff --git a/lib/views/main_view/main_view_widgets/no_token_screen.dart b/lib/views/main_view/main_view_widgets/no_token_screen.dart index 74f8b8f81..56b54eab9 100644 --- a/lib/views/main_view/main_view_widgets/no_token_screen.dart +++ b/lib/views/main_view/main_view_widgets/no_token_screen.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../../../l10n/app_localizations.dart'; class NoTokenScreen extends StatelessWidget { - const NoTokenScreen({Key? key}) : super(key: key); + const NoTokenScreen({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/actions/edit_day_password_token_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/actions/edit_day_password_token_action.dart index 6c2a803ec..92a6bcf03 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/actions/edit_day_password_token_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/actions/edit_day_password_token_action.dart @@ -1,9 +1,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../../l10n/app_localizations.dart'; import '../../../../../../model/tokens/day_password_token.dart'; import '../../../../../../utils/app_customizer.dart'; import '../../../../../../utils/customizations.dart'; @@ -17,14 +17,14 @@ class EditDayPassowrdTokenAction extends TokenAction { final DayPasswordToken token; const EditDayPassowrdTokenAction({ - Key? key, + super.key, required this.token, - }) : super(key: key); + }); @override CustomSlidableAction build(BuildContext context) => CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.renameColorLight : ApplicationCustomizer.renameColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.editColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.editLockedToken) == false) { return; @@ -51,6 +51,7 @@ class EditDayPassowrdTokenAction extends TokenAction { final algorithm = token.algorithm; showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) => BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), @@ -79,8 +80,9 @@ class EditDayPassowrdTokenAction extends TokenAction { softWrap: false, ), onPressed: () async { - final newToken = token.copyWith(label: tokenLabel.text, tokenImage: imageUrl.text, period: period, algorithm: algorithm); - globalRef?.read(tokenProvider.notifier).updateToken(newToken); + globalRef + ?.read(tokenProvider.notifier) + .updateToken(token, (p0) => p0.copyWith(label: tokenLabel.text, tokenImage: imageUrl.text, period: period, algorithm: algorithm)); Navigator.of(context).pop(); }), ], diff --git a/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/day_password_token_widget_tile.dart b/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/day_password_token_widget_tile.dart index 3701b1732..152fc381e 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/day_password_token_widget_tile.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/day_password_token_widgets/day_password_token_widget_tile.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/tokens/day_password_token.dart'; import '../../../../../utils/identifiers.dart'; import '../../../../../utils/lock_auth.dart'; @@ -104,11 +104,11 @@ class _DayPasswordTokenWidgetTileState extends ConsumerState p0.copyWith(viewMode: DayPasswordTokenViewMode.VALIDUNTIL)); return; } if (widget.token.viewMode == DayPasswordTokenViewMode.VALIDUNTIL) { - globalRef?.read(tokenProvider.notifier).updateToken(widget.token.copyWith(viewMode: DayPasswordTokenViewMode.VALIDFOR)); + globalRef?.read(tokenProvider.notifier).updateToken(widget.token, (p0) => p0.copyWith(viewMode: DayPasswordTokenViewMode.VALIDFOR)); return; } }, diff --git a/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_delete_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_delete_action.dart index 511d6c930..02a0e7ff3 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_delete_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_delete_action.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/tokens/token.dart'; import '../../../../../utils/app_customizer.dart'; import '../../../../../utils/customizations.dart'; @@ -18,10 +18,10 @@ class DefaultDeleteAction extends TokenAction { @override CustomSlidableAction build(BuildContext context) { return CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.deleteColorLight : ApplicationCustomizer.deleteColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.deleteColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (_) async { - if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.deleteLockedToken) == false) { + if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)?.deleteLockedToken ?? '') == false) { return; } _showDialog(); @@ -41,38 +41,41 @@ class DefaultDeleteAction extends TokenAction { ); } - void _showDialog() => showDialog( - context: globalNavigatorKey.currentContext!, - builder: (BuildContext context) { - return DefaultDialog( - scrollable: true, - title: Text( - AppLocalizations.of(context)!.confirmDeletion, - ), - content: Text( - AppLocalizations.of(context)!.confirmDeletionOf(token.label), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - AppLocalizations.of(context)!.cancel, - overflow: TextOverflow.fade, - softWrap: false, + void _showDialog() => globalNavigatorKey.currentContext == null + ? null + : showDialog( + useRootNavigator: false, + context: globalNavigatorKey.currentContext!, + builder: (BuildContext context) { + return DefaultDialog( + scrollable: true, + title: Text( + AppLocalizations.of(context)!.confirmDeletion, ), - ), - TextButton( - onPressed: () { - globalRef?.read(tokenProvider.notifier).removeToken(token); - Navigator.of(context).pop(); - }, - child: Text( - AppLocalizations.of(context)!.delete, - overflow: TextOverflow.fade, - softWrap: false, + content: Text( + AppLocalizations.of(context)!.confirmDeletionOf(token.label), ), - ), - ], - ); - }); + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + AppLocalizations.of(context)!.cancel, + overflow: TextOverflow.fade, + softWrap: false, + ), + ), + TextButton( + onPressed: () { + globalRef?.read(tokenProvider.notifier).removeToken(token); + Navigator.of(context).pop(); + }, + child: Text( + AppLocalizations.of(context)!.delete, + overflow: TextOverflow.fade, + softWrap: false, + ), + ), + ], + ); + }); } diff --git a/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_edit_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_edit_action.dart index c0c942bc2..6779b52cf 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_edit_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_edit_action.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/tokens/token.dart'; import '../../../../../utils/app_customizer.dart'; import '../../../../../utils/customizations.dart'; @@ -13,13 +13,13 @@ import '../token_action.dart'; class DefaultEditAction extends TokenAction { final Token token; - const DefaultEditAction({required this.token, Key? key}) : super(key: key); + const DefaultEditAction({required this.token, super.key}); @override CustomSlidableAction build(BuildContext context) { return CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.renameColorLight : ApplicationCustomizer.renameColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.editColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.editLockedToken) == false) { return; @@ -43,6 +43,7 @@ class DefaultEditAction extends TokenAction { void _showDialog() { TextEditingController nameInputController = TextEditingController(text: token.label); showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) { return DefaultDialog( @@ -82,7 +83,7 @@ class DefaultEditAction extends TokenAction { onPressed: () { final newLabel = nameInputController.text.trim(); if (newLabel.isEmpty) return; - globalRef?.read(tokenProvider.notifier).updateToken(token.copyWith(label: newLabel)); + globalRef?.read(tokenProvider.notifier).updateToken(token, (p0) => p0.copyWith(label: newLabel)); Logger.info( 'Renamed token:', diff --git a/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_lock_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_lock_action.dart index db024e80e..ce8735496 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_lock_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/default_token_actions/default_lock_action.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/tokens/token.dart'; import '../../../../../utils/app_customizer.dart'; import '../../../../../utils/lock_auth.dart'; @@ -12,18 +12,18 @@ import '../token_action.dart'; class DefaultLockAction extends TokenAction { final Token token; - const DefaultLockAction({required this.token, Key? key}) : super(key: key); + const DefaultLockAction({required this.token, super.key}); @override CustomSlidableAction build(BuildContext context) { return CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.lockColorLight : ApplicationCustomizer.lockColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.lockColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { - Logger.info('Changing lock status of token ${token.label}.', name: 'token_widgets.dart#_changeLockStatus'); + Logger.info('Changing lock status of token.', name: 'token_widgets.dart#_changeLockStatus'); if (await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.authenticateToUnLockToken) == false) return; - globalRef?.read(tokenProvider.notifier).updateToken(token.copyWith(isLocked: !token.isLocked)); + globalRef?.read(tokenProvider.notifier).updateToken(token, (p0) => p0.copyWith(isLocked: !token.isLocked)); }, child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/actions/edit_hotp_token_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/actions/edit_hotp_token_action.dart index 4d2b8f5f4..2e2d98a54 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/actions/edit_hotp_token_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/actions/edit_hotp_token_action.dart @@ -1,9 +1,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../../l10n/app_localizations.dart'; import '../../../../../../model/tokens/hotp_token.dart'; import '../../../../../../utils/app_customizer.dart'; import '../../../../../../utils/customizations.dart'; @@ -17,14 +17,14 @@ class EditHOTPTokenAction extends TokenAction { final HOTPToken token; const EditHOTPTokenAction({ - Key? key, + super.key, required this.token, - }) : super(key: key); + }); @override CustomSlidableAction build(BuildContext context) => CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.renameColorLight : ApplicationCustomizer.renameColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.editColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.editLockedToken) == false) { return; @@ -50,6 +50,7 @@ class EditHOTPTokenAction extends TokenAction { final algorithm = token.algorithm; showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) => BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), @@ -74,8 +75,9 @@ class EditHOTPTokenAction extends TokenAction { softWrap: false, ), onPressed: () async { - final newToken = token.copyWith(label: tokenLabel.text, tokenImage: imageUrl.text, algorithm: algorithm); - globalRef?.read(tokenProvider.notifier).updateToken(newToken); + globalRef + ?.read(tokenProvider.notifier) + .updateToken(token, (p0) => p0.copyWith(label: tokenLabel.text, tokenImage: imageUrl.text, algorithm: algorithm)); Navigator.of(context).pop(); }), ], diff --git a/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/hotp_token_widget_tile.dart b/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/hotp_token_widget_tile.dart index 56236e77f..3f26e8d21 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/hotp_token_widget_tile.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/hotp_token_widgets/hotp_token_widget_tile.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/tokens/hotp_token.dart'; import '../../../../../utils/lock_auth.dart'; import '../../../../../utils/riverpod_providers.dart'; diff --git a/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/actions/edit_push_token_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/actions/edit_push_token_action.dart index e71023bc3..e2ee62e4c 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/actions/edit_push_token_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/actions/edit_push_token_action.dart @@ -1,15 +1,13 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../../l10n/app_localizations.dart'; import '../../../../../../model/tokens/push_token.dart'; +import '../../../../../../repo/secure_token_repository.dart'; import '../../../../../../utils/app_customizer.dart'; import '../../../../../../utils/customizations.dart'; import '../../../../../../utils/lock_auth.dart'; import '../../../../../../utils/riverpod_providers.dart'; -import '../../../../../../utils/storage_utils.dart'; import '../../../../../../widgets/default_dialog.dart'; import '../../../../../../widgets/enable_text_form_field_after_many_taps.dart'; import '../../token_action.dart'; @@ -18,14 +16,14 @@ class EditPushTokenAction extends TokenAction { final PushToken token; const EditPushTokenAction({ - Key? key, + super.key, required this.token, - }) : super(key: key); + }); @override CustomSlidableAction build(BuildContext context) => CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.renameColorLight : ApplicationCustomizer.renameColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.editColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.editLockedToken) == false) { return; @@ -53,6 +51,7 @@ class EditPushTokenAction extends TokenAction { final publicTokenKey = token.publicTokenKey; showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) => DefaultDialog( scrollable: true, @@ -79,12 +78,14 @@ class EditPushTokenAction extends TokenAction { softWrap: false, ), onPressed: () async { - final newToken = token.copyWith( - label: tokenLabel.text, - url: Uri.parse(pushUrl.text), - tokenImage: imageUrl.text, - ); - globalRef?.read(tokenProvider.notifier).updateToken(newToken); + globalRef?.read(tokenProvider.notifier).updateToken( + token, + (p0) => p0.copyWith( + label: tokenLabel.text, + url: Uri.parse(pushUrl.text), + tokenImage: imageUrl.text, + ), + ); Navigator.of(context).pop(); }), ], @@ -164,7 +165,7 @@ class EditPushTokenAction extends TokenAction { ); } }, - future: StorageUtil.getCurrentFirebaseToken(), + future: SecureTokenRepository.getCurrentFirebaseToken(), ), ], ), diff --git a/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/push_token_widget.dart b/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/push_token_widget.dart index e2e52b13c..cceb41355 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/push_token_widget.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/push_token_widget.dart @@ -1,16 +1,16 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'rollout_failed_widget.dart'; -import 'rollout_widget.dart'; import '../../../../../model/mixins/sortable_mixin.dart'; import '../../../../../model/tokens/push_token.dart'; import '../../../../../utils/identifiers.dart'; import '../token_widget.dart'; -import 'actions/edit_push_token_action.dart'; import '../token_widget_base.dart'; +import 'actions/edit_push_token_action.dart'; import 'push_token_widget_tile.dart'; +import 'rollout_failed_widget.dart'; +import 'rollout_widget.dart'; class PushTokenWidget extends TokenWidget { final PushToken token; diff --git a/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/rollout_failed_widget.dart b/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/rollout_failed_widget.dart index 03f867004..868c4dee4 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/rollout_failed_widget.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/push_token_widgets/rollout_failed_widget.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/tokens/push_token.dart'; import '../../../../../utils/customizations.dart'; import '../../../../../utils/riverpod_providers.dart'; @@ -64,6 +64,7 @@ class RolloutFailedWidget extends StatelessWidget { } void _showDialog() => showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) { return DefaultDialog( diff --git a/lib/views/main_view/main_view_widgets/token_widgets/token_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/token_action.dart index 40929ed92..3f090f818 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/token_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/token_action.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; abstract class TokenAction extends StatelessWidget { - const TokenAction({Key? key}) : super(key: key); + const TokenAction({super.key}); @override CustomSlidableAction build(BuildContext context); } diff --git a/lib/views/main_view/main_view_widgets/token_widgets/token_widget.dart b/lib/views/main_view/main_view_widgets/token_widgets/token_widget.dart index 6f479dcdf..4a81e4871 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/token_widget.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/token_widget.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'token_widget_base.dart'; abstract class TokenWidget extends StatelessWidget { - const TokenWidget({Key? key}) : super(key: key); + const TokenWidget({super.key}); @override TokenWidgetBase build(BuildContext context); diff --git a/lib/views/main_view/main_view_widgets/token_widgets/token_widget_builder.dart b/lib/views/main_view/main_view_widgets/token_widgets/token_widget_builder.dart index 48d5d85cb..161074c7a 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/token_widget_builder.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/token_widget_builder.dart @@ -18,10 +18,10 @@ abstract class TokenWidgetBuilder { Key? key, }) => switch (token.runtimeType) { - TOTPToken => TOTPTokenWidget(token as TOTPToken, key: key), - HOTPToken => HOTPTokenWidget(token as HOTPToken, key: key), - PushToken => PushTokenWidget(token as PushToken, key: key), - DayPasswordToken => DayPasswordTokenWidget(token as DayPasswordToken, key: key), + const (TOTPToken) => TOTPTokenWidget(token as TOTPToken, key: key), + const (HOTPToken) => HOTPTokenWidget(token as HOTPToken, key: key), + const (PushToken) => PushTokenWidget(token as PushToken, key: key), + const (DayPasswordToken) => DayPasswordTokenWidget(token as DayPasswordToken, key: key), _ => throw UnimplementedError('Token type (${token.runtimeType}) not supported in this Version of the App') }; } diff --git a/lib/views/main_view/main_view_widgets/token_widgets/token_widget_slideable.dart b/lib/views/main_view/main_view_widgets/token_widgets/token_widget_slideable.dart index a7999b1fc..071b3fc7e 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/token_widget_slideable.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/token_widget_slideable.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; -import 'token_action.dart'; import '../../../../model/tokens/token.dart'; +import 'token_action.dart'; class TokenWidgetSlideable extends StatelessWidget { final Token token; diff --git a/lib/views/main_view/main_view_widgets/token_widgets/token_widget_tile.dart b/lib/views/main_view/main_view_widgets/token_widgets/token_widget_tile.dart index e13345fe6..ee2e13ac9 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/token_widget_tile.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/token_widget_tile.dart @@ -1,9 +1,8 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; + import '../../../../widgets/custom_trailing.dart'; final disableCopyOtpProvider = StateProvider((ref) => false); diff --git a/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/actions/edit_totp_token_action.dart b/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/actions/edit_totp_token_action.dart index 6331df30c..5a358db20 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/actions/edit_totp_token_action.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/actions/edit_totp_token_action.dart @@ -1,9 +1,7 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import '../../../../../../l10n/app_localizations.dart'; import '../../../../../../model/tokens/totp_token.dart'; import '../../../../../../utils/app_customizer.dart'; import '../../../../../../utils/customizations.dart'; @@ -17,14 +15,14 @@ class EditTOTPTokenAction extends TokenAction { final TOTPToken token; const EditTOTPTokenAction({ - Key? key, + super.key, required this.token, - }) : super(key: key); + }); @override CustomSlidableAction build(BuildContext context) => CustomSlidableAction( - backgroundColor: Theme.of(context).brightness == Brightness.light ? ApplicationCustomizer.renameColorLight : ApplicationCustomizer.renameColorDark, - foregroundColor: Theme.of(context).brightness == Brightness.light ? Colors.black : Colors.white, + backgroundColor: Theme.of(context).extension()!.editColor, + foregroundColor: Theme.of(context).extension()!.foregroundColor, onPressed: (context) async { if (token.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.editLockedToken) == false) { return; @@ -51,6 +49,7 @@ class EditTOTPTokenAction extends TokenAction { final algorithm = token.algorithm; showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) => DefaultDialog( scrollable: true, @@ -77,8 +76,9 @@ class EditTOTPTokenAction extends TokenAction { softWrap: false, ), onPressed: () async { - final newToken = token.copyWith(label: tokenLabel.text, tokenImage: imageUrl.text, period: period, algorithm: algorithm); - globalRef?.read(tokenProvider.notifier).updateToken(newToken); + globalRef + ?.read(tokenProvider.notifier) + .updateToken(token, (p0) => p0.copyWith(label: tokenLabel.text, tokenImage: imageUrl.text, period: period, algorithm: algorithm)); Navigator.of(context).pop(); }), ], diff --git a/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/totp_token_widget_tile.dart b/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/totp_token_widget_tile.dart index ecdc44fb3..e7cd5a9c4 100644 --- a/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/totp_token_widget_tile.dart +++ b/lib/views/main_view/main_view_widgets/token_widgets/totp_token_widgets/totp_token_widget_tile.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../../l10n/app_localizations.dart'; import '../../../../../model/states/app_state.dart'; import '../../../../../model/tokens/totp_token.dart'; import '../../../../../utils/lock_auth.dart'; diff --git a/lib/views/onboarding_view/onboading_view_widgets/onboarding_page.dart b/lib/views/onboarding_view/onboading_view_widgets/onboarding_page.dart index 69d51e063..d18515677 100644 --- a/lib/views/onboarding_view/onboading_view_widgets/onboarding_page.dart +++ b/lib/views/onboarding_view/onboading_view_widgets/onboarding_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class OnboardingPage extends StatelessWidget { - const OnboardingPage({Key? key, required, required this.title, required this.subtitle, this.onPressed, this.buttonTitle}) : super(key: key); + const OnboardingPage({super.key, required, required this.title, required this.subtitle, this.onPressed, this.buttonTitle}); final String title; final String subtitle; @@ -11,21 +11,24 @@ class OnboardingPage extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - title, - style: TextStyle( - fontSize: 27.0, - fontWeight: FontWeight.bold, - color: Theme.of(context).textTheme.titleMedium?.color, + Padding( + padding: const EdgeInsets.all(24.0), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + title, + style: TextStyle( + fontSize: 27.0, + fontWeight: FontWeight.bold, + color: Theme.of(context).textTheme.titleMedium?.color, + ), ), ), ), - const SizedBox(height: 40), Wrap( crossAxisAlignment: WrapCrossAlignment.center, alignment: WrapAlignment.center, @@ -42,14 +45,17 @@ class OnboardingPage extends StatelessWidget { ), ), if (onPressed != null && buttonTitle != null) - OutlinedButton( - onPressed: onPressed, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - buttonTitle!, - overflow: TextOverflow.fade, - softWrap: false, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: OutlinedButton( + onPressed: onPressed, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + buttonTitle!, + overflow: TextOverflow.fade, + softWrap: false, + ), ), ), ), diff --git a/lib/views/onboarding_view/onboarding_view.dart b/lib/views/onboarding_view/onboarding_view.dart index 91a09d63d..8c76e0810 100644 --- a/lib/views/onboarding_view/onboarding_view.dart +++ b/lib/views/onboarding_view/onboarding_view.dart @@ -1,12 +1,9 @@ -import 'package:flare_flutter/flare_actor.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lottie/lottie.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:url_launcher/url_launcher.dart'; -import '../../utils/app_customizer.dart'; +import '../../l10n/app_localizations.dart'; import '../../utils/riverpod_providers.dart'; import '../../widgets/dot_indicator.dart'; import '../main_view/main_view.dart'; @@ -32,8 +29,9 @@ List lottieFiles = [ class OnboardingView extends ConsumerStatefulWidget { static const String routeName = '/onboarding'; + final String appName; - const OnboardingView({Key? key}) : super(key: key); + const OnboardingView({required this.appName, super.key}); @override ConsumerState createState() => _OnboardingViewState(); @@ -42,37 +40,29 @@ class OnboardingView extends ConsumerStatefulWidget { class _OnboardingViewState extends ConsumerState { int _currentIndex = 0; final PageController _pageController = PageController(); - String animation = 'Untitled'; @override - Widget build(BuildContext context) { - var screenSize = MediaQuery.of(context).size; - return Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - body: Stack( - children: [ - SizedBox( - height: screenSize.height / 1.4, - width: screenSize.width, - ), - Positioned( - top: 120, - right: 5, - left: 5, - child: SizedBox( - width: screenSize.width * 0.4, - height: screenSize.height * 0.4, - child: Lottie.asset( - lottieFiles[_currentIndex].lottieFile, - alignment: Alignment.topCenter, + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const Expanded(child: SizedBox()), + Expanded( + flex: 3, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Lottie.asset( + lottieFiles[_currentIndex].lottieFile, + alignment: Alignment.topCenter, + ), + ), ), ), - ), - Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - height: 270, + Expanded( + flex: 2, child: Column( children: [ Flexible( @@ -82,12 +72,15 @@ class _OnboardingViewState extends ConsumerState { itemBuilder: (BuildContext context, int index) { if (_currentIndex == 0) { return OnboardingPage( - title: AppLocalizations.of(context)!.onBoardingTitle1(ApplicationCustomizer.appName), - subtitle: AppLocalizations.of(context)!.onBoardingText1); + title: AppLocalizations.of(context)!.onBoardingTitle1(widget.appName), + subtitle: AppLocalizations.of(context)!.onBoardingText1, + ); } if (_currentIndex == 1) { - // TODO guide removed from here, put the new one here again? - return OnboardingPage(title: AppLocalizations.of(context)!.onBoardingTitle2, subtitle: AppLocalizations.of(context)!.onBoardingText2); + return OnboardingPage( + title: AppLocalizations.of(context)!.onBoardingTitle2, + subtitle: AppLocalizations.of(context)!.onBoardingText2, + ); } if (_currentIndex == 2) { return OnboardingPage( @@ -95,70 +88,54 @@ class _OnboardingViewState extends ConsumerState { subtitle: AppLocalizations.of(context)!.onBoardingText3, buttonTitle: 'Github', onPressed: () async { - String url = "https://github.com/privacyidea/pi-authenticator"; - if (await canLaunchUrlString(url)) { - await launchUrlString(url); - } else { - throw 'Could not launch $url'; + Uri uri = Uri.parse("https://github.com/privacyidea/pi-authenticator"); + if (!await launchUrl(uri)) { + throw Exception('Could not launch $uri'); } }, ); } + return Container(); }, onPageChanged: (value) { - _currentIndex = value; - setState(() {}); + setState(() { + _currentIndex = value; + }); }, ), ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (int index = 0; index < lottieFiles.length; index++) DotIndicator(isSelected: index == _currentIndex), - ], - ), - const SizedBox(height: 75) ], ), ), - ), - ], - ), - floatingActionButton: FloatingActionButton( - onPressed: () { - final isFirstRun = ref.read(settingsProvider).isFirstRun; - - switch (_currentIndex) { - case 2: - if (isFirstRun) { - ref.read(settingsProvider.notifier).setFirstRun(false); - Navigator.pushReplacementNamed( - context, - MainView.routeName, - ); - } else { - Navigator.of(context).pop(); - } - break; - default: - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.ease, - ); - } - }, - backgroundColor: Theme.of(context).brightness == Brightness.dark ? const Color(0xFF303030) : Colors.grey[50], - child: _currentIndex == 2 - ? FlareActor( - 'res/rive/success_check.flr', - animation: animation, - ) - : Icon( - CupertinoIcons.right_chevron, - color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (int index = 0; index < lottieFiles.length; index++) DotIndicator(isSelected: index == _currentIndex), + ], ), - ), - ); - } + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + if (_currentIndex == lottieFiles.length - 1) { + ref.read(settingsProvider.notifier).setFirstRun(false); + Navigator.of(context).pushReplacementNamed(MainView.routeName); + return; + } + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ); + }, + backgroundColor: Theme.of(context).colorScheme.background, + child: Icon( + Icons.arrow_forward_ios_outlined, + color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, + ), + ), + ); } diff --git a/lib/views/push_token_view/push_tokens_view.dart b/lib/views/push_token_view/push_tokens_view.dart new file mode 100644 index 000000000..d57ec8b1c --- /dev/null +++ b/lib/views/push_token_view/push_tokens_view.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'widgets/push_tokens_view_list.dart'; + +class PushTokensView extends StatelessWidget { + static const routeName = '/pushTokensView'; + const PushTokensView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Push Tokens'), + ), + body: Stack( + children: [ + Center( + child: Icon(Icons.notifications_none, size: 300, color: Colors.grey.withOpacity(0.2)), + ), + const PushTokensViwList(), + ], + ), + ); + } +} diff --git a/lib/views/push_token_view/widgets/push_tokens_view_list.dart b/lib/views/push_token_view/widgets/push_tokens_view_list.dart new file mode 100644 index 000000000..84f20ef4d --- /dev/null +++ b/lib/views/push_token_view/widgets/push_tokens_view_list.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../model/mixins/sortable_mixin.dart'; +import '../../../model/token_folder.dart'; +import '../../../utils/push_provider.dart'; +import '../../../utils/riverpod_providers.dart'; +import '../../../utils/view_utils.dart'; +import '../../../widgets/deactivateable_refresh_indicator.dart'; +import '../../../widgets/drag_item_scroller.dart'; +import '../../../widgets/push_request_overlay.dart'; +import '../../main_view/main_view_widgets/drag_target_divider.dart'; +import '../../main_view/main_view_widgets/sortable_widget_builder.dart'; + +class PushTokensViwList extends ConsumerStatefulWidget { + const PushTokensViwList({super.key}); + + @override + ConsumerState createState() => _PushTokensViwListState(); +} + +class _PushTokensViwListState extends ConsumerState { + final listViewKey = GlobalKey(); + final scrollController = ScrollController(); + + Duration? lastTimeStamp; + + @override + Widget build(BuildContext context) { + final tokenState = ref.watch(tokenProvider); + final pushTokens = tokenState.pushTokens; + final allowToRefresh = pushTokens.isNotEmpty; + final draggingSortable = ref.watch(draggingSortableProvider); + final tokenWithPushRequest = tokenState.tokenWithPushRequest(); + + List sortables = [...pushTokens]; + return Stack( + children: [ + DeactivateableRefreshIndicator( + allowToRefresh: allowToRefresh, + onRefresh: () async { + showMessage( + message: AppLocalizations.of(context)!.pollingChallenges, + duration: const Duration(seconds: 1), + ); + final errorMessage = await PushProvider().pollForChallenges(showMessageForEachToken: true); + if (errorMessage != null) showMessage(message: errorMessage); + }, + child: SlidableAutoCloseBehavior( + child: DragItemScroller( + listViewKey: listViewKey, + itemIsDragged: draggingSortable != null, + scrollController: scrollController, + child: CustomScrollView( + key: listViewKey, + physics: allowToRefresh ? const AlwaysScrollableScrollPhysics() : null, + controller: scrollController, + slivers: [ + SliverList( + delegate: SliverChildListDelegate( + [..._buildSortableWidgets(sortables, draggingSortable)], + ), + ), + ], + ), + ), + ), + ), + if (tokenWithPushRequest != null) PushRequestOverlay(tokenWithPushRequest), + ], + ); + } +} + +List _buildSortableWidgets(List sortables, SortableMixin? draggingSortable) { + List widgets = []; + if (sortables.isEmpty) return widgets; + sortables.sort((a, b) => a.compareTo(b)); + for (var i = 0; i < sortables.length; i++) { + final isFirst = i == 0; + final isDraggingTheCurrent = draggingSortable == sortables[i]; + final previousWasExpandedFolder = i > 0 && sortables[i - 1] is TokenFolder && (sortables[i - 1] as TokenFolder).isExpanded; + // 1. Add a divider if the current sortable is not the one which is dragged + // 2. Dont add a divider if the current sortable is the first + // 3. Dont add a divider if the previous sortable was an expanded folder + // 4. Ignore 2. and 3. if there is a sortable that is dragged + // 1 2 3 4 + if (!isDraggingTheCurrent && ((!isFirst && !previousWasExpandedFolder) || draggingSortable != null)) { + widgets.add( + DragTargetDivider(dependingFolder: null, nextSortable: sortables[i], ignoreFolderId: true), + ); + } + widgets.add(SortableWidgetBuilder.fromSortable(sortables[i])); + } + if (draggingSortable != null) { + widgets.add(const DragTargetDivider(dependingFolder: null, nextSortable: null, isLastDivider: true, ignoreFolderId: true)); + } + widgets.add(const SizedBox(height: 80)); + return widgets; +} diff --git a/lib/views/qr_scanner_view/qr_scanner_view.dart b/lib/views/qr_scanner_view/qr_scanner_view.dart new file mode 100644 index 000000000..25617d1b5 --- /dev/null +++ b/lib/views/qr_scanner_view/qr_scanner_view.dart @@ -0,0 +1,88 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; +import 'package:privacyidea_authenticator/widgets/default_dialog.dart'; +import 'package:privacyidea_authenticator/widgets/default_dialog_button.dart'; + +import 'qr_scanner_view_widgets/qr_scanner_widget.dart'; + +class QRScannerView extends StatelessWidget { + static const routeName = '/qr_scanner'; + + const QRScannerView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + leading: IconButton( + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + size: 32, + ), + onPressed: () { + Navigator.pop(context, null); + }), + ), + extendBodyBehindAppBar: true, + body: FutureBuilder( + future: Permission.camera.request(), + builder: (context, isGranted) { + if (isGranted.connectionState != ConnectionState.done) return const SizedBox(); + if (isGranted.data == PermissionStatus.permanentlyDenied) { + return DefaultDialog( + title: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogTitle), + content: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogPermanentlyDenied), + ); + } + if (isGranted.data != PermissionStatus.granted) { + return DefaultDialog( + title: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogTitle), + content: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogContent), + actions: [ + DefaultDialogButton( + child: Text(AppLocalizations.of(context)!.grantCameraPermissionDialogButton), + onPressed: () { + //Trigger the permission to request it + Permission.camera.request(); + }, + ), + DefaultDialogButton( + child: Text(AppLocalizations.of(context)!.cancel), + onPressed: () { + Navigator.pop(context, null); + }, + ), + ], + ); + } + return const QRScannerWidget(); + }, + ), + ); + } +} diff --git a/lib/views/qr_scanner_view/qr_scanner_view_widgets/qr_scanner_widget.dart b/lib/views/qr_scanner_view/qr_scanner_view_widgets/qr_scanner_widget.dart new file mode 100644 index 000000000..2d5f141ef --- /dev/null +++ b/lib/views/qr_scanner_view/qr_scanner_view_widgets/qr_scanner_widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import 'qr_code_scanner_overlay.dart'; + +class QRScannerWidget extends StatefulWidget { + // final _key = GlobalKey(); + const QRScannerWidget({super.key}); + + @override + State createState() => _QRScannerWidgetState(); +} + +class _QRScannerWidgetState extends State { + bool alreadyDetected = false; + @override + Widget build(BuildContext context) => SizedBox.expand( + child: Stack( + alignment: Alignment.center, + children: [ + MobileScanner( + fit: BoxFit.contain, + controller: MobileScannerController( + // facing: CameraFacing.back, + // torchEnabled: false, + returnImage: false, + ), + onDetect: (capture) { + if (alreadyDetected) return; + alreadyDetected = true; + final List barcodes = capture.barcodes; + for (final barcode in barcodes) { + debugPrint('Barcode found! ${barcode.rawValue}'); + } + + Navigator.pop(context, barcodes.first.rawValue); + }, + ), + Container( + decoration: const ShapeDecoration(shape: ScannerOverlayShape()), + ) + ], + ), + ); +} diff --git a/lib/views/qr_scanner_view/scanner_view.dart b/lib/views/qr_scanner_view/scanner_view.dart deleted file mode 100644 index 1cd5288bc..000000000 --- a/lib/views/qr_scanner_view/scanner_view.dart +++ /dev/null @@ -1,99 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:privacyidea_authenticator/utils/view_utils.dart'; -import 'package:qr_mobile_vision/qr_camera.dart'; - -import '../../utils/logger.dart'; -import 'qr_scanner_view_widgets/qr_code_scanner_overlay.dart'; - -class QRScannerView extends StatelessWidget { - static const routeName = '/qr_scanner'; - - final _key = GlobalKey(); - - QRScannerView({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - resizeToAvoidBottomInset: false, - backgroundColor: Colors.transparent, - appBar: AppBar( - backgroundColor: Colors.transparent, - leading: IconButton( - icon: const Icon( - Icons.arrow_back, - color: Colors.white, - size: 32, - ), - onPressed: () { - Navigator.pop(context, null); - }), - ), - extendBodyBehindAppBar: true, - body: SizedBox.expand( - child: Stack( - alignment: Alignment.center, - children: [ - QrCamera( - fit: BoxFit.cover, - key: _key, - formats: const [BarcodeFormats.QR_CODE], - // Ignore other codes than qr codes - onError: (context, e) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (e is PlatformException && e.message == 'noPermission') { - Logger.warning( - 'QRScannerView: Camera permission not granted.', - name: 'QRScannerView#build#onError', - error: e, - stackTrace: StackTrace.current, - ); - showMessage(message: 'Please grant camera permission to use the QR scanner.'); - } - Navigator.pop(context, null); - _key.currentState!.stop(); - - // Method must return a widget, so return one that does not display anything. - }); - return const SizedBox(); - }, - // We have nothing to display in these cases, overwrite default - // behaviour with 'non-visible' content. - child: const SizedBox(), - notStartedBuilder: (_) => const SizedBox(), - offscreenBuilder: (_) => const SizedBox(), - qrCodeCallback: (code) { - Navigator.pop(context, code); - _key.currentState!.stop(); - }, - ), - Container( - decoration: const ShapeDecoration(shape: ScannerOverlayShape()), - ) - ], - ), - ), - ); - } -} diff --git a/lib/views/settings_view/settings_view.dart b/lib/views/settings_view/settings_view.dart index 3123a928e..b5d44b3bc 100644 --- a/lib/views/settings_view/settings_view.dart +++ b/lib/views/settings_view/settings_view.dart @@ -1,10 +1,11 @@ import 'package:easy_dynamic_theme/easy_dynamic_theme.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../l10n/app_localizations.dart'; import '../../model/tokens/push_token.dart'; import '../../utils/riverpod_providers.dart'; +import '../license_view/license_view.dart'; import 'settings_view_widgets/logging_menu.dart'; import 'settings_view_widgets/settings_groups.dart'; import 'settings_view_widgets/update_firebase_token_dialog.dart'; @@ -16,11 +17,7 @@ class SettingsView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(settingsProvider); final tokens = ref.watch(tokenProvider).tokens; - final locale = settings.currentLocale; - final useSystemLocale = settings.useSystemLocale; - final enablePolling = settings.enablePolling; final enrolledPushTokenList = tokens.whereType().where((e) => e.isRolledOut).toList(); final unsupported = enrolledPushTokenList.where((e) => e.url == null).toList(); final showPushSettingsGroup = enrolledPushTokenList.isNotEmpty; @@ -38,10 +35,10 @@ class SettingsView extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ SettingsGroup( title: AppLocalizations.of(context)!.theme, - children: [ + children: [ RadioListTile( title: Text( AppLocalizations.of(context)!.lightTheme, @@ -91,19 +88,19 @@ class SettingsView extends ConsumerWidget { AppLocalizations.of(context)!.useDeviceLocaleDescription, overflow: TextOverflow.fade, ), - value: useSystemLocale, + value: ref.watch(settingsProvider).useSystemLocale, onChanged: (value) => ref.read(settingsProvider.notifier).setUseSystemLocale(value)), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: DropdownButton( disabledHint: Text( - '$locale', - style: Theme.of(context).textTheme.titleMedium!.copyWith(color: Colors.grey), + '${ref.watch(settingsProvider).currentLocale}', + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.grey), overflow: TextOverflow.fade, softWrap: false, ), isExpanded: true, - value: locale, + value: ref.watch(settingsProvider).currentLocale, items: AppLocalizations.supportedLocales.map>((Locale itemLocale) { return DropdownMenuItem( value: itemLocale, @@ -114,7 +111,7 @@ class SettingsView extends ConsumerWidget { ), ); }).toList(), - onChanged: useSystemLocale ? null : (value) => ref.read(settingsProvider.notifier).setLocale(value!), + onChanged: ref.watch(settingsProvider).useSystemLocale ? null : (value) => ref.read(settingsProvider.notifier).setLocalePreference(value!), ), ), ], @@ -123,7 +120,7 @@ class SettingsView extends ConsumerWidget { visible: showPushSettingsGroup, child: SettingsGroup( title: AppLocalizations.of(context)!.pushToken, - children: [ + children: [ ListTile( title: Text( AppLocalizations.of(context)!.synchronizePushTokens, @@ -141,6 +138,7 @@ class SettingsView extends ConsumerWidget { ), onPressed: () { showDialog( + useRootNavigator: false, context: context, barrierDismissible: false, builder: (context) => const UpdateFirebaseTokenDialog(), @@ -179,10 +177,31 @@ class SettingsView extends ConsumerWidget { overflow: TextOverflow.fade, ), trailing: Switch( - value: enablePolling, + value: ref.watch(settingsProvider).enablePolling, onChanged: (value) => ref.read(settingsProvider.notifier).setPolling(value), ), ), + // if (ref.watch(tokenProvider).hasHOTPTokens) + // ListTile( + // title: RichText( + // text: TextSpan( + // children: [ + // TextSpan( + // text: AppLocalizations.of(context)!.hidePushTokens, + // style: Theme.of(context).textTheme.titleMedium, + // ), + // ], + // ), + // ), + // subtitle: Text( + // AppLocalizations.of(context)!.hidePushTokensDescription, + // overflow: TextOverflow.fade, + // ), + // trailing: Switch( + // value: ref.watch(settingsProvider).hidePushTokensState != HidePushTokens.notHidden, + // onChanged: (value) => ref.read(settingsProvider.notifier).setHidePushTokens(isHidden: value), + // ), + // ) ], ), ), @@ -205,9 +224,24 @@ class SettingsView extends ConsumerWidget { onPressed: () => showDialog( context: context, builder: (context) => const LoggingMenu(), + useRootNavigator: false, ), ), ), + const Divider(), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, LicenseView.routeName); + }, + child: ListTile( + title: Text( + AppLocalizations.of(context)!.licensesAndVersion, + style: Theme.of(context).textTheme.titleMedium, + overflow: TextOverflow.fade, + softWrap: false, + ), + ), + ) ]), ], ), diff --git a/lib/views/settings_view/settings_view_widgets/logging_menu.dart b/lib/views/settings_view/settings_view_widgets/logging_menu.dart index 8323b4768..8e6d3a2ad 100644 --- a/lib/views/settings_view/settings_view_widgets/logging_menu.dart +++ b/lib/views/settings_view/settings_view_widgets/logging_menu.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../utils/logger.dart'; import '../../../utils/riverpod_providers.dart'; import '../../../widgets/default_dialog.dart'; @@ -90,9 +90,17 @@ class LoggingMenu extends ConsumerWidget { void _pressSendErrorLog(BuildContext context) { if (Logger.instance.logfileHasContent) { - showDialog(context: context, builder: (context) => const SendErrorDialog()); + showDialog( + useRootNavigator: false, + context: context, + builder: (context) => const SendErrorDialog(), + ); } else { - showDialog(context: context, builder: (context) => const NoLogDialog()); + showDialog( + useRootNavigator: false, + context: context, + builder: (context) => const NoLogDialog(), + ); } } diff --git a/lib/views/settings_view/settings_view_widgets/send_error_dialog.dart b/lib/views/settings_view/settings_view_widgets/send_error_dialog.dart index 863f4d9f9..d1a88d670 100644 --- a/lib/views/settings_view/settings_view_widgets/send_error_dialog.dart +++ b/lib/views/settings_view/settings_view_widgets/send_error_dialog.dart @@ -1,8 +1,8 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../../../l10n/app_localizations.dart'; import '../../../utils/logger.dart'; import '../../../widgets/default_dialog.dart'; diff --git a/lib/views/settings_view/settings_view_widgets/update_firebase_token_dialog.dart b/lib/views/settings_view/settings_view_widgets/update_firebase_token_dialog.dart index e704dbe72..b4b0f17ea 100644 --- a/lib/views/settings_view/settings_view_widgets/update_firebase_token_dialog.dart +++ b/lib/views/settings_view/settings_view_widgets/update_firebase_token_dialog.dart @@ -18,19 +18,14 @@ limitations under the License. */ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:http/http.dart'; -import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; import 'package:privacyidea_authenticator/utils/logger.dart'; -import 'package:privacyidea_authenticator/utils/network_utils.dart'; -import 'package:privacyidea_authenticator/utils/push_provider.dart'; -import 'package:privacyidea_authenticator/utils/storage_utils.dart'; +import 'package:privacyidea_authenticator/utils/view_utils.dart'; import '../../../model/tokens/push_token.dart'; import '../../../utils/customizations.dart'; +import '../../../utils/push_provider.dart'; import '../../../widgets/default_dialog.dart'; class UpdateFirebaseTokenDialog extends StatefulWidget { @@ -70,65 +65,16 @@ class _UpdateFirebaseTokenDialogState extends State { void _updateFbTokens() async { Logger.info('Starting update of firebase token.', name: 'update_firebase_token_dialog.dart#_updateFbTokens'); - List tokenList = (await StorageUtil.loadAllTokens()).whereType().toList(); - // TODO What to do with poll only tokens if google-services is used? - String? token = await PushProvider.getFBToken(); - - // TODO Is there a good way to handle these tokens? - List tokenWithOutUrl = tokenList.where((e) => e.url == null).toList(); - List tokenWithUrl = tokenList.where((e) => e.url != null).toList(); - List tokenWithFailedUpdate = []; - - for (PushToken pushToken in tokenWithUrl) { - // POST /ttype/push HTTP/1.1 - // Host: example.com - // - // new_fb_token= - // serial=element - // timestamp= - // signature=SIGNATURE(||) - - String timestamp = DateTime.now().toUtc().toIso8601String(); - - String message = '$token|${pushToken.serial}|$timestamp'; - String? signature = await trySignWithToken(pushToken, message); - if (signature == null) { - return; - } - - Response response; - try { - response = await doPost( - sslVerify: pushToken.sslVerify, - url: pushToken.url!, - body: {'new_fb_token': token, 'serial': pushToken.serial, 'timestamp': timestamp, 'signature': signature}); - - if (response.statusCode == 200) { - Logger.info('Updating firebase token for push token: ${pushToken.serial} succeeded!', name: 'update_firebase_token_dialog.dart#_updateFbTokens'); - } else { - Logger.warning('Updating firebase token for push token: ${pushToken.serial} failed!', name: 'update_firebase_token_dialog.dart#_updateFbTokens'); - tokenWithFailedUpdate.add(pushToken); - } - } on SocketException catch (e, s) { - Logger.warning( - 'Socket exception occurred: $e', - name: 'update_firebase_token_dialog.dart#_updateFbTokens', - stackTrace: s, - ); - ScaffoldMessenger.of(globalNavigatorKey.currentContext!).showSnackBar(SnackBar( - content: Text( - AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorSynchronizationNoNetworkConnection, - overflow: TextOverflow.fade, - softWrap: false, - ), - duration: const Duration(seconds: 3), - )); - Navigator.pop(globalNavigatorKey.currentContext!); - return; - } + final tuple = await PushProvider.updateFirebaseToken(); + if (tuple == null) { + showMessage(message: AppLocalizations.of(globalNavigatorKey.currentContext!)!.errorSynchronizationNoNetworkConnection); + return; } + late List tokenWithFailedUpdate; + late List tokenWithOutUrl; + (tokenWithFailedUpdate, tokenWithOutUrl) = tuple; if (tokenWithFailedUpdate.isEmpty && tokenWithOutUrl.isEmpty) { if (!mounted) return; diff --git a/lib/views/splash_screen/splash_screen.dart b/lib/views/splash_screen/splash_screen.dart new file mode 100644 index 000000000..b2de3c15f --- /dev/null +++ b/lib/views/splash_screen/splash_screen.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../utils/logger.dart'; +import '../../utils/riverpod_providers.dart'; +import '../main_view/main_view.dart'; +import '../onboarding_view/onboarding_view.dart'; + +class SplashScreen extends ConsumerStatefulWidget { + static const routeName = '/'; + + final Widget appImage; + final Widget appIcon; + final String appName; + + const SplashScreen({required this.appImage, required this.appIcon, required this.appName, super.key}); + + @override + ConsumerState createState() => _SplashScreenState(); +} + +class _SplashScreenState extends ConsumerState { + var _appIconIsVisible = false; + final _splashScreenDuration = const Duration(milliseconds: 400); + final _splashScreenDelay = const Duration(milliseconds: 250); + + @override + void initState() { + super.initState(); + + Logger.info('Starting app.', name: 'main.dart#initState'); + Future.delayed(_splashScreenDelay, () { + if (mounted) { + setState(() { + _appIconIsVisible = true; + }); + } + }); + _init(); + } + + Future _init() async { + await Future.delayed(_splashScreenDuration + _splashScreenDelay * 2); + final isFirstRun = ref.read(settingsProvider).isFirstRun; + final ConsumerStatefulWidget nextView; + if (isFirstRun) { + nextView = OnboardingView(appName: widget.appName); + } else { + nextView = MainView(appName: widget.appName, appIcon: widget.appIcon); + } + // ignore: use_build_context_synchronously + Navigator.pushReplacement( + context, + PageRouteBuilder( + pageBuilder: (_, __, ___) => nextView, + transitionDuration: _splashScreenDuration * 2, + transitionsBuilder: (_, a, __, c) => FadeTransition(opacity: a, child: c), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: false, + body: Center( + child: AnimatedOpacity( + opacity: _appIconIsVisible ? 1.0 : 0.0, + duration: _splashScreenDuration, + child: Padding( + padding: const EdgeInsets.all(32.0), + child: widget.appImage, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart new file mode 100644 index 000000000..2ea8b9fd8 --- /dev/null +++ b/lib/widgets/app_wrapper.dart @@ -0,0 +1,16 @@ +import 'package:easy_dynamic_theme/easy_dynamic_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AppWrapper extends StatelessWidget { + final Widget child; + + const AppWrapper({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + child: EasyDynamicThemeWidget(child: child), + ); + } +} diff --git a/lib/widgets/custom_texts.dart b/lib/widgets/custom_texts.dart index d4daa80aa..8b1a4c883 100644 --- a/lib/widgets/custom_texts.dart +++ b/lib/widgets/custom_texts.dart @@ -46,7 +46,7 @@ class HideableText extends StatelessWidget { final ValueNotifier isHiddenNotifier; const HideableText({ - Key? key, + super.key, required this.text, required this.isHiddenNotifier, this.hideOnDefault = true, @@ -55,7 +55,7 @@ class HideableText extends StatelessWidget { this.enabled = true, this.replaceCharacter = '\u2022', this.replaceWhitespaces = false, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/widgets/custom_trailing.dart b/lib/widgets/custom_trailing.dart index 68ea485e1..1397dc218 100644 --- a/lib/widgets/custom_trailing.dart +++ b/lib/widgets/custom_trailing.dart @@ -1,15 +1,17 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; class CustomTrailing extends StatelessWidget { final Widget child; - const CustomTrailing({required this.child, Key? key}) : super(key: key); + const CustomTrailing({required this.child, super.key}); @override Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; return SizedBox( - width: MediaQuery.of(context).size.width * 0.275, - height: MediaQuery.of(context).size.width * 0.20, + width: min(85, size.width * 0.275), child: child, ); } diff --git a/lib/views/main_view/deactivateable_refresh_indicator.dart b/lib/widgets/deactivateable_refresh_indicator.dart similarity index 93% rename from lib/views/main_view/deactivateable_refresh_indicator.dart rename to lib/widgets/deactivateable_refresh_indicator.dart index 20a8e12e2..0edab269b 100644 --- a/lib/views/main_view/deactivateable_refresh_indicator.dart +++ b/lib/widgets/deactivateable_refresh_indicator.dart @@ -6,11 +6,11 @@ class DeactivateableRefreshIndicator extends StatelessWidget { final Future Function() onRefresh; const DeactivateableRefreshIndicator({ - Key? key, + super.key, required this.child, required this.allowToRefresh, required this.onRefresh, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/widgets/default_dialog.dart b/lib/widgets/default_dialog.dart index 714988411..f354f4fda 100644 --- a/lib/widgets/default_dialog.dart +++ b/lib/widgets/default_dialog.dart @@ -26,7 +26,7 @@ class DefaultDialog extends StatelessWidget { buttonPadding: const EdgeInsets.fromLTRB(8, 0, 8, 8), insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), titlePadding: const EdgeInsets.all(12), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + contentPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), title: title, actions: actions, content: content, diff --git a/lib/widgets/default_dialog_button.dart b/lib/widgets/default_dialog_button.dart new file mode 100644 index 000000000..26f2ae754 --- /dev/null +++ b/lib/widgets/default_dialog_button.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class DefaultDialogButton extends StatelessWidget { + final Widget child; + final Function()? onPressed; + + const DefaultDialogButton({required this.child, this.onPressed, super.key}); + + @override + Widget build(BuildContext context) => TextButton(onPressed: onPressed, child: child); +} diff --git a/lib/widgets/dot_indicator.dart b/lib/widgets/dot_indicator.dart index d6e4d5504..819737321 100644 --- a/lib/widgets/dot_indicator.dart +++ b/lib/widgets/dot_indicator.dart @@ -3,16 +3,14 @@ import 'package:flutter/material.dart'; class DotIndicator extends StatelessWidget { final bool isSelected; - const DotIndicator({Key? key, required this.isSelected}) : super(key: key); + const DotIndicator({super.key, required this.isSelected}); @override Widget build(BuildContext context) { final ThemeData mode = Theme.of(context); - Color circleBackgroundColor = - mode.brightness == Brightness.dark ? Colors.white38 : Colors.grey; + Color circleBackgroundColor = mode.brightness == Brightness.dark ? Colors.white38 : Colors.grey; - Color selectedColor = - mode.brightness == Brightness.dark ? Colors.white : Colors.black; + Color selectedColor = mode.brightness == Brightness.dark ? Colors.white : Colors.black; return Padding( padding: const EdgeInsets.only(right: 6.0), diff --git a/lib/widgets/hideable_widget_.dart b/lib/widgets/hideable_widget_.dart index 5f88fe327..647a99207 100644 --- a/lib/widgets/hideable_widget_.dart +++ b/lib/widgets/hideable_widget_.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../l10n/app_localizations.dart'; import '../model/tokens/token.dart'; import '../utils/lock_auth.dart'; diff --git a/lib/widgets/hideable_widget_trailing.dart b/lib/widgets/hideable_widget_trailing.dart index efcc21626..da98f8b55 100644 --- a/lib/widgets/hideable_widget_trailing.dart +++ b/lib/widgets/hideable_widget_trailing.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import '../l10n/app_localizations.dart'; import '../model/tokens/token.dart'; import '../utils/lock_auth.dart'; diff --git a/lib/widgets/pulse_icon.dart b/lib/widgets/pulse_icon.dart new file mode 100644 index 000000000..7974700f2 --- /dev/null +++ b/lib/widgets/pulse_icon.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +class PulseIcon extends StatefulWidget { + const PulseIcon({required this.size, required this.child, this.isPulsing = true, super.key}); + + final double size; + final Widget child; + final bool isPulsing; + + @override + State createState() => _PulseIconState(); +} + +class _PulseIconState extends State with SingleTickerProviderStateMixin { + late AnimationController _animationController; + + late Animation _scaleAnimation; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + if (widget.isPulsing) { + _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1500))..repeat(); + _scaleAnimation = Tween(begin: 0.5, end: 1.5).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )); + _opacityAnimation = Tween(begin: 1.0, end: 0.0).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Stack( + children: [ + if (widget.isPulsing) + Center( + child: FadeTransition( + opacity: _opacityAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.secondary, + ), + width: widget.size, + height: widget.size, + ), + ), + ), + ), + Center(child: widget.child), + ], + ); +} diff --git a/lib/views/main_view/main_view_widgets/push_request_overlay.dart b/lib/widgets/push_request_overlay.dart similarity index 88% rename from lib/views/main_view/main_view_widgets/push_request_overlay.dart rename to lib/widgets/push_request_overlay.dart index 8cef94d96..694f3b2bd 100644 --- a/lib/views/main_view/main_view_widgets/push_request_overlay.dart +++ b/lib/widgets/push_request_overlay.dart @@ -1,14 +1,14 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:privacyidea_authenticator/utils/customizations.dart'; -import 'package:privacyidea_authenticator/utils/lock_auth.dart'; -import 'package:privacyidea_authenticator/widgets/default_dialog.dart'; -import 'package:privacyidea_authenticator/widgets/press_button.dart'; -import '../../../model/tokens/push_token.dart'; -import '../../../utils/riverpod_providers.dart'; +import '../l10n/app_localizations.dart'; +import '../model/tokens/push_token.dart'; +import '../utils/customizations.dart'; +import '../utils/lock_auth.dart'; +import '../utils/riverpod_providers.dart'; +import 'default_dialog.dart'; +import 'press_button.dart'; class PushRequestOverlay extends StatelessWidget { final PushToken tokenWithPushRequest; @@ -41,10 +41,7 @@ class PushRequestOverlay extends StatelessWidget { onPressed: () async { if (tokenWithPushRequest.isLocked && await lockAuth(context: context, localizedReason: AppLocalizations.of(context)!.authToAcceptPushRequest) == false) return; - final pr = tokenWithPushRequest.pushRequests.peek(); - if (pr != null) { - globalRef?.read(pushRequestProvider.notifier).accept(pr); - } + globalRef?.read(pushRequestProvider.notifier).acceptPop(tokenWithPushRequest); }, child: FittedBox( fit: BoxFit.scaleDown, @@ -103,6 +100,7 @@ class PushRequestOverlay extends StatelessWidget { } void _showDialog(PushToken token) => showDialog( + useRootNavigator: false, context: globalNavigatorKey.currentContext!, builder: (BuildContext context) { return BackdropFilter( @@ -157,10 +155,7 @@ void _showDialog(PushToken token) => showDialog( flex: 3, child: PressButton( onPressed: () { - final pr = token.pushRequests.peek(); - if (pr != null) { - globalRef?.read(pushRequestProvider.notifier).decline(pr); - } + globalRef?.read(pushRequestProvider.notifier).declinePop(token); Navigator.of(context).pop(); }, child: Column( @@ -191,10 +186,7 @@ void _showDialog(PushToken token) => showDialog( style: ButtonStyle(backgroundColor: MaterialStateProperty.all(Theme.of(context).colorScheme.errorContainer)), onPressed: () { //TODO: Notify issuer - final pr = token.pushRequests.peek(); - if (pr != null) { - globalRef?.read(pushRequestProvider.notifier).decline(pr); - } + globalRef?.read(pushRequestProvider.notifier).declinePop(token); Navigator.of(context).pop(); }, child: Column( diff --git a/lib/widgets/two_step_dialog.dart b/lib/widgets/two_step_dialog.dart index 1c0d59c90..11f8819a6 100644 --- a/lib/widgets/two_step_dialog.dart +++ b/lib/widgets/two_step_dialog.dart @@ -18,53 +18,87 @@ limitations under the License. */ +import 'dart:developer'; import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:privacyidea_authenticator/l10n/app_localizations.dart'; import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; import 'package:privacyidea_authenticator/utils/utils.dart'; +import 'package:privacyidea_authenticator/utils/view_utils.dart'; import 'package:privacyidea_authenticator/widgets/default_dialog.dart'; -class TwoStepDialog extends StatefulWidget { +import 'widget_keys.dart'; + +class GenerateTwoStepDialog extends StatelessWidget { final int _saltLength; final int _iterations; final int _keyLength; final Uint8List _password; - const TwoStepDialog({super.key, required int saltLength, required int iterations, required int keyLength, required Uint8List password}) + const GenerateTwoStepDialog({super.key, required int saltLength, required int iterations, required int keyLength, required Uint8List password}) : _saltLength = saltLength, _iterations = iterations, _keyLength = keyLength, _password = password; - @override - State createState() => _TwoStepDialogState(); -} + void _do2Step(BuildContext context) async { + // 1. Generate salt. + final Uint8List salt = secureRandom().nextBytes(_saltLength); -class _TwoStepDialogState extends State { - late String _title; - Widget _content = const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [CircularProgressIndicator()], - ); - VoidCallback? _onPressed; - late Uint8List _generatedSecret; + // 2. Generate secret. + final Uint8List generatedSecret = await pbkdf2( + salt: salt, + iterations: _iterations, + keyLength: _keyLength, + password: _password, + ); - @override - void initState() { - super.initState(); - _do2Step(); + String phoneChecksum = await generatePhoneChecksum(phonePart: salt); + if (!context.mounted) { + log('GenerateTwoStepDialog: context is not mounted anymore. Aborting.'); + return; + } + + // 3. Show phone part if this widget is still mounted. + Navigator.of(context).pop(generatedSecret); + showAsyncDialog( + barrierDismissible: false, + builder: (context) => TwoStepDialog( + phoneChecksum: phoneChecksum, + )); } @override - void didChangeDependencies() { - super.didChangeDependencies(); - - _title = AppLocalizations.of(context)!.generatingPhonePart; + Widget build(BuildContext context) { + _do2Step(context); + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: DefaultDialog( + scrollable: true, + title: Text( + AppLocalizations.of(context)!.generatingPhonePart, + overflow: TextOverflow.fade, + softWrap: false, + ), + content: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [CircularProgressIndicator()], + ), + ), + ); } +} +class TwoStepDialog extends StatefulWidget { + final String phoneChecksum; + const TwoStepDialog({super.key, required this.phoneChecksum}); + @override + State createState() => _TwoStepDialogState(); +} + +class _TwoStepDialogState extends State { @override Widget build(BuildContext context) { return BackdropFilter( @@ -75,14 +109,17 @@ class _TwoStepDialogState extends State { child: DefaultDialog( scrollable: true, title: Text( - _title, + AppLocalizations.of(context)!.phonePart, overflow: TextOverflow.fade, softWrap: false, ), - content: _content, + content: Text( + splitPeriodically(widget.phoneChecksum, 4), + key: twoStepDialogContent, + ), actions: [ TextButton( - onPressed: _onPressed, + onPressed: () => Navigator.of(context).pop(), child: Text( AppLocalizations.of(context)!.dismiss, overflow: TextOverflow.fade, @@ -94,31 +131,4 @@ class _TwoStepDialogState extends State { ), ); } - - void _do2Step() async { - // 1. Generate salt. - Uint8List salt = secureRandom().nextBytes(widget._saltLength); - - // 2. Generate secret. - _generatedSecret = await pbkdf2( - salt: salt, - iterations: widget._iterations, - keyLength: widget._keyLength, - password: widget._password, - ); - - // 3. Show phone part. - String phoneChecksum = await generatePhoneChecksum(phonePart: salt); - String show = splitPeriodically(phoneChecksum, 4); - // Update UI. - setState(() { - _title = AppLocalizations.of(context)!.phonePart; - _content = Text( - show, - overflow: TextOverflow.fade, - softWrap: false, - ); - _onPressed = () => Navigator.of(context).pop(_generatedSecret); - }); - } } diff --git a/lib/widgets/widget_keys.dart b/lib/widgets/widget_keys.dart new file mode 100644 index 000000000..241d15323 --- /dev/null +++ b/lib/widgets/widget_keys.dart @@ -0,0 +1,5 @@ +import 'package:flutter/foundation.dart'; + +// const two_step_dialog_title = ValueKey('two_step_dialog_title'); +const twoStepDialogContent = ValueKey('two_step_dialog_content'); +// const two_step_dialog_button = ValueKey('two_step_dialog_button'); diff --git a/local_plugins/pi-authenticator-legacy/lib/pi_authenticator_legacy.dart b/local_plugins/pi-authenticator-legacy/lib/pi_authenticator_legacy.dart index a77e91ad2..f3e9f7779 100644 --- a/local_plugins/pi-authenticator-legacy/lib/pi_authenticator_legacy.dart +++ b/local_plugins/pi-authenticator-legacy/lib/pi_authenticator_legacy.dart @@ -33,10 +33,11 @@ const String PARAMETER_MESSAGE = "message"; const String PARAMETER_SIGNED_DATA = "signedData"; const String PARAMETER_SIGNATURE = "signature"; -class Legacy { +class LegacyUtils { + const LegacyUtils(); static const MethodChannel _channel = const MethodChannel(METHOD_CHANNEL_ID); - static Future sign(String serial, String message) async => await (_channel.invokeMethod(METHOD_SIGN, { + Future sign(String serial, String message) async => await (_channel.invokeMethod(METHOD_SIGN, { PARAMETER_SERIAL: serial, PARAMETER_MESSAGE: message, }).catchError((dynamic, stackTrace) { @@ -48,7 +49,7 @@ class Legacy { throw PlatformException(message: "Signing failed.", code: LEGACY_SIGNING_ERROR); })); - static Future verify(String serial, String signedData, String signature) async => await (_channel.invokeMethod(METHOD_VERIFY, { + Future verify(String serial, String signedData, String signature) async => await (_channel.invokeMethod(METHOD_VERIFY, { PARAMETER_SERIAL: serial, PARAMETER_SIGNED_DATA: signedData, PARAMETER_SIGNATURE: signature, diff --git a/pubspec.lock b/pubspec.lock index 2e546ff1f..040dad27b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "2d8e8e123ca3675625917f535fcc0d3a50092eef44334168f9b18adc050d4c6e" + sha256: "7bcb5c5d62b3907fb4a269c0f0843df46760d38e12829a715f2ff1fb492f19ef" url: "https://pub.dev" source: hosted - version: "1.3.6" + version: "1.3.10" analyzer: dependency: transitive description: @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: archive - sha256: "20071638cbe4e5964a427cfa0e86dce55d060bc7d82d56f3554095d7239a8765" + sha256: "7e0d52067d05f2e0324268097ba723b71cb41ac8a6a2b24d1edf9c536b987b03" url: "https://pub.dev" source: hosted - version: "3.4.2" + version: "3.4.6" args: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: build_resolvers - sha256: "0713a05b0386bd97f9e63e78108805a4feca5898a4b821d6610857f10c91e975" + sha256: "64e12b0521812d1684b1917bc80945625391cb9bdd4312536b1d69dcb6133ed8" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" build_runner: dependency: "direct dev" description: @@ -189,10 +189,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "77a180d6938f78ca7d2382d2240eb626c0f6a735d0bfdce227d8ffb80f95c48b" + sha256: b502a681ba415272ecc41400bd04fe543ed1a62632137dc84d25a91e7746f55f url: "https://pub.dev" source: hosted - version: "4.0.2" + version: "5.0.1" connectivity_plus_platform_interface: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: "7035152271ff67b072a211152846e9f1259cf1be41e34cd3e0b5463d2d6b8419" url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "9.1.0" device_info_plus_platform_interface: dependency: transitive description: @@ -273,6 +273,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" expandable: dependency: "direct main" description: @@ -317,50 +325,50 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "675c209c94a1817649137cbd113fc4c9ae85e48d03dd578629abbec6d8a4d93d" + sha256: "37299e4907391d7fac8c7ea059bb3292768cc07b72b6c6c777675cc58da2ef4d" url: "https://pub.dev" source: hosted - version: "2.16.0" + version: "2.20.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: b63e3be6c96ef5c33bdec1aab23c91eb00696f6452f0519401d640938c94cba2 + sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "5.0.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: e8c408923cd3a25bd342c576a114f2126769cd1a57106a4edeaa67ea4a84e962 + sha256: "0631a2ec971dbc540275e2fa00c3a8a2676f0a7adbc3c197d6fba569db689d97" url: "https://pub.dev" source: hosted - version: "2.8.0" + version: "2.8.1" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "4544524c22de3ffdc7e0ffaeeba212a04d09e76d0549ae6f42ce285d9d8f0513" + sha256: d7b6f9c394f8575598fa94e67220cdd2097a0bc28ce20a3ef2db2da94ede5b47 url: "https://pub.dev" source: hosted - version: "14.6.8" + version: "14.7.2" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: a6e1fae8242a14d5d8f5ab1cf94693511f06bab49ff1d46e3d83c0af3c4becb8 + sha256: "825356880eb94bf16ea7ef6a71384d2c32cc88143b54ecffed8b3987f7178854" url: "https://pub.dev" source: hosted - version: "4.5.7" + version: "4.5.11" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: a9fe837dc2dcdd3e32e6109a6b0ce62592d7a44cb8f69cb5b73190865c5aa28e + sha256: "9c82e55c4b470970c77a99c90733adff6be7d5a67251007727b2a98d4f04e0cd" url: "https://pub.dev" source: hosted - version: "3.5.7" + version: "3.5.11" fixnum: dependency: transitive description: @@ -399,18 +407,18 @@ packages: dependency: "direct main" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: ad76540d21c066228ee3f9d1dad64a9f7e46530e8bb7c85011a88bc1fd874bc5 url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "3.0.0" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: "3002092e5b8ce2f86c3361422e52e6db6776c23ee21e0b2f71b892bf4259ef04" + sha256: "6d11ea777496061e583623aaf31923f93a9409ef8fcaeeefdd6cd78bf4fe5bb3" url: "https://pub.dev" source: hosted - version: "15.1.1" + version: "16.1.0" flutter_local_notifications_linux: dependency: transitive description: @@ -444,26 +452,26 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: a10979814c5f4ddbe2b6143fba25d927599e21e3ba65b3862995960606fae78f + sha256: "8afc9a6aa6d8e8063523192ba837149dbf3d377a37c0b0fc579149a1fbd4a619" url: "https://pub.dev" source: hosted - version: "0.6.17+3" + version: "0.6.18" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.17" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "1bd39b04f1bcd217a969589777ca6bd642d116e3e5de65c3e6a8e8bdd8b178ec" + sha256: bdba94be666ecb1beeb0f5a748d96cdd6a37215f27e6b48c7673b95cecb800c8 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" flutter_secure_storage: dependency: "direct main" description: @@ -615,6 +623,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.3" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -667,10 +680,10 @@ packages: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "3.0.0" local_auth: dependency: "direct main" description: @@ -683,10 +696,10 @@ packages: dependency: "direct main" description: name: local_auth_android - sha256: "9ad0b1ffa6f04f4d91e38c2d4c5046583e23f4cae8345776a994e8670df57fb1" + sha256: df4ccb3193525b8a60c78a5ca7bf188a47705bcf77bcc837a6b2cf6da64ae0e2 url: "https://pub.dev" source: hosted - version: "1.0.34" + version: "1.0.35" local_auth_ios: dependency: "direct main" description: @@ -731,10 +744,10 @@ packages: dependency: "direct main" description: name: lottie - sha256: b8bdd54b488c54068c57d41ae85d02808da09e2bee8b8dd1f59f441e7efa60cd + sha256: a93542cc2d60a7057255405f62252533f8e8956e7e06754955669fd32fb4b216 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" markdown: dependency: transitive description: @@ -775,8 +788,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - mockito: + mobile_scanner: dependency: "direct main" + description: + name: mobile_scanner + sha256: c7b819851c031f73d225b1c1fc84f903b951eb6188278e63bf4e7a961cebfe99 + url: "https://pub.dev" + source: hosted + version: "3.5.1" + mockito: + dependency: "direct dev" description: name: mockito sha256: "7d5b53bcd556c1bc7ffbe4e4d5a19c3e112b7e925e9e172dd7c6ad0630812616" @@ -787,18 +808,10 @@ packages: dependency: "direct main" description: name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" + sha256: "8827da25de792088eb33e572115a5eb0d61d61a3c01acbc8bcbe76ed78f1a1f2" url: "https://pub.dev" source: hosted - version: "3.0.1" - native_device_orientation: - dependency: transitive - description: - name: native_device_orientation - sha256: "7d2fa1a1420b7b4ac3317760ffd063b86704679fb4f1796644581da4a6356ccc" - url: "https://pub.dev" - source: hosted - version: "1.1.4" + version: "3.1.0" nm: dependency: transitive description: @@ -835,10 +848,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" + sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" package_info_plus_platform_interface: dependency: transitive description: @@ -867,10 +880,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: e595b98692943b4881b219f0a9e3945118d3c16bd7e2813f98ec6e532d905f72 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" path_provider_foundation: dependency: transitive description: @@ -907,18 +920,18 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: ad65ba9af42a3d067203641de3fd9f547ded1410bad3b84400c2b4899faede70 + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" url: "https://pub.dev" source: hosted - version: "11.0.0" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 + sha256: f9fddd3b46109bd69ff3f9efa5006d2d309b7aec0f3c1c5637a60a2d5659e76e url: "https://pub.dev" source: hosted - version: "11.0.5" + version: "11.1.0" permission_handler_apple: dependency: transitive description: @@ -931,10 +944,10 @@ packages: dependency: transitive description: name: permission_handler_platform_interface - sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 + sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" url: "https://pub.dev" source: hosted - version: "3.11.5" + version: "3.12.0" permission_handler_windows: dependency: transitive description: @@ -1014,30 +1027,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" - qr_mobile_vision: - dependency: "direct main" - description: - name: qr_mobile_vision - sha256: bd17a9eb6227a0072fc1a9b0eaf8fc791560188417740f254fee0b9335239b1d - url: "https://pub.dev" - source: hosted - version: "4.1.3" riverpod: dependency: transitive description: name: riverpod - sha256: a600120d6f213a9922860eea1abc32597436edd5b2c4e73b91410f8c2af67d22 + sha256: "2af3d127a6e4e34b89b8f1f018086f5ded04b8e538174f0510bba3e4c0d878b1" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_android: dependency: transitive description: @@ -1058,10 +1063,10 @@ packages: dependency: transitive description: name: shared_preferences_linux - sha256: c2eb5bf57a2fe9ad6988121609e47d3e07bb3bdca5b6f8444e4cf302428a128a + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_platform_interface: dependency: transitive description: @@ -1082,10 +1087,10 @@ packages: dependency: transitive description: name: shared_preferences_windows - sha256: f763a101313bd3be87edffe0560037500967de9c394a714cd598d945517f694f + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shelf: dependency: transitive description: @@ -1311,66 +1316,66 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + sha256: b1c9e98774adf8820c96fbc7ae3601231d324a7d5ebd8babe27b6dfac91357ba url: "https://pub.dev" source: hosted - version: "6.1.14" + version: "6.2.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.2.0" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + sha256: "4ac97281cf60e2e8c5cc703b2b28528f9b50c8f7cebc71df6bdf0845f647268a" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.2.0" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + sha256: "9f2d390e096fdbe1e6e6256f97851e51afc2d9c423d3432f1d6a02a8a9a8b9fd" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.0" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + sha256: "7fd2f55fe86cea2897b963e864dc01a7eb0719ecc65fcef4c1cc3d686d718bb2" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.2.0" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + sha256: "7754a1ad30ee896b265f8d14078b0513a4dba28d358eabb9d5f339886f4a1adc" url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.1.0" uuid: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9bde47dfc..4c68b43f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ publish_to: none # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 4.2.0+402019 # TODO Set the right version number +version: 4.2.1+402102 # TODO Set the right version number # version: major.minor.build + 2x major|2x minor|3x build # version: version number + build number (optional) # android: build-name + versionCode @@ -34,9 +34,7 @@ dependencies: hex: ^0.2.0 base32: ^2.1.1 otp: ^3.0.1 - qr_mobile_vision: ^4.1.3 flutter_secure_storage: ^9.0.0 - json_annotation: ^4.8.1 flutter_slidable: ^3.0.0 package_info_plus: ^4.0.2 asn1lib: ^1.0.3 @@ -47,7 +45,7 @@ dependencies: http: ^1.1.0 pointycastle: ^3.4.0 mutex: ^3.0.0 - flutter_lints: ^2.0.2 + flutter_lints: ^3.0.0 expandable: ^5.0.1 flutterlifecyclehooks: ^4.0.0 streaming_shared_preferences: ^2.0.0 @@ -69,19 +67,24 @@ dependencies: shared_preferences: ^2.2.0 lifecycle: ^0.8.0 flutter_launcher_icons: ^0.13.1 - flutter_local_notifications: ^15.1.0+1 + flutter_local_notifications: ^16.1.0 extended_nested_scroll_view: ^6.1.2 local_auth_ios: ^1.1.3 local_auth_android: ^1.0.32 - connectivity_plus: ^4.0.2 + connectivity_plus: ^5.0.1 device_info_plus: ^9.0.3 - mockito: ^5.4.2 + json_annotation: ^4.8.1 + equatable: ^2.0.5 + mobile_scanner: ^3.4.1 dev_dependencies: flutter_driver: sdk: flutter flutter_test: sdk: flutter + integration_test: + sdk: flutter + mockito: ^5.4.2 test: ^1.24.1 @@ -108,18 +111,12 @@ flutter: # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg assets: - - res/logo/app_logo_light.png + - res/logo/ - CHANGELOG.md - res/guide/ - - res/gif/help_delete_rename.gif - - res/gif/help_manual_poll.gif - - res/lottie/onboarding_secure_animation.json - - res/rive/success_check.flr - - res/lottie/security-cloud.json - - res/lottie/941-submit-smile.json - - res/lottie/lock_shield.json - - res/logo/github.png - - res/lottie/github-logo.json + - res/gif/ + - res/lottie/ + - res/rive/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. @@ -149,10 +146,10 @@ flutter: flutter_launcher_icons: android: true - ios: true - image_path: "res/logo/app_logo_light.png" + image_path: "customization/temp/app_icon.png" min_sdk_android: 21 # android min sdk min:16, default 21 - remove_alpha_ios: true adaptive_icon_background: "#FFFFFF" - adaptive_icon_foreground: "res/logo/app_logo_light_adaptive_foreground.png" - + adaptive_icon_foreground: "customization/temp/app_icon_adaptive.png" + ios: true + image_path_ios: "customization/temp/app_icon_ios.png" + remove_alpha_ios: false # default false \ No newline at end of file diff --git a/res/logo/app_logo_light.png b/res/logo/app_logo_light.png index 13e90f515..93f382a73 100644 Binary files a/res/logo/app_logo_light.png and b/res/logo/app_logo_light.png differ diff --git a/res/logo/app_logo_light_small.png b/res/logo/app_logo_light_small.png new file mode 100644 index 000000000..ac8fd771b Binary files /dev/null and b/res/logo/app_logo_light_small.png differ diff --git a/run_driver.sh b/run_driver.sh old mode 100755 new mode 100644 diff --git a/test/tests_app_wrapper.dart b/test/tests_app_wrapper.dart new file mode 100644 index 000000000..a423269e1 --- /dev/null +++ b/test/tests_app_wrapper.dart @@ -0,0 +1,50 @@ +import 'package:easy_dynamic_theme/easy_dynamic_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/settings_repository.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/token_folder_repository.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/token_repository.dart'; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart'; +import 'package:privacyidea_authenticator/utils/network_utils.dart'; +import 'package:privacyidea_authenticator/utils/qr_parser.dart'; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +class TestsAppWrapper extends StatelessWidget { + final Widget child; + final List overrides; + + const TestsAppWrapper({super.key, required this.child, this.overrides = const []}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: overrides, + child: EasyDynamicThemeWidget(child: child), + ); + } +} + +Future pumpUntilFindNWidgets(WidgetTester tester, Finder finder, int n, Duration timeOut) async { + final startTime = DateTime.now(); + while (true) { + await tester.pump(); + if (DateTime.now().difference(startTime) > timeOut) { + break; + } + if (tester.widgetList(finder).length == n) { + break; + } + await Future.delayed(const Duration(milliseconds: 500)); + } +} diff --git a/test/tests_app_wrapper.mocks.dart b/test/tests_app_wrapper.mocks.dart new file mode 100644 index 000000000..c4e36bb6d --- /dev/null +++ b/test/tests_app_wrapper.mocks.dart @@ -0,0 +1,581 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in privacyidea_authenticator/test/tests_app_wrapper.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; +import 'dart:typed_data' as _i14; + +import 'package:firebase_messaging/firebase_messaging.dart' as _i17; +import 'package:http/http.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:pointycastle/export.dart' as _i4; +import 'package:privacyidea_authenticator/interfaces/repo/settings_repository.dart' + as _i8; +import 'package:privacyidea_authenticator/interfaces/repo/token_folder_repository.dart' + as _i9; +import 'package:privacyidea_authenticator/interfaces/repo/token_repository.dart' + as _i5; +import 'package:privacyidea_authenticator/model/states/settings_state.dart' + as _i2; +import 'package:privacyidea_authenticator/model/token_folder.dart' as _i10; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart' as _i15; +import 'package:privacyidea_authenticator/model/tokens/token.dart' as _i7; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart' as _i16; +import 'package:privacyidea_authenticator/utils/network_utils.dart' as _i11; +import 'package:privacyidea_authenticator/utils/qr_parser.dart' as _i12; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart' as _i13; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSettingsState_0 extends _i1.SmartFake implements _i2.SettingsState { + _FakeSettingsState_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeResponse_1 extends _i1.SmartFake implements _i3.Response { + _FakeResponse_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRSAPublicKey_2 extends _i1.SmartFake implements _i4.RSAPublicKey { + _FakeRSAPublicKey_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRSAPrivateKey_3 extends _i1.SmartFake implements _i4.RSAPrivateKey { + _FakeRSAPrivateKey_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAsymmetricKeyPair_4 extends _i1.SmartFake + implements _i4.AsymmetricKeyPair { + _FakeAsymmetricKeyPair_4( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [TokenRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTokenRepository extends _i1.Mock implements _i5.TokenRepository { + @override + _i6.Future> saveOrReplaceTokens(List<_i7.Token>? tokens) => + (super.noSuchMethod( + Invocation.method( + #saveOrReplaceTokens, + [tokens], + ), + returnValue: _i6.Future>.value(<_i7.Token>[]), + returnValueForMissingStub: + _i6.Future>.value(<_i7.Token>[]), + ) as _i6.Future>); + + @override + _i6.Future> loadTokens() => (super.noSuchMethod( + Invocation.method( + #loadTokens, + [], + ), + returnValue: _i6.Future>.value(<_i7.Token>[]), + returnValueForMissingStub: + _i6.Future>.value(<_i7.Token>[]), + ) as _i6.Future>); + + @override + _i6.Future> deleteTokens(List<_i7.Token>? tokens) => + (super.noSuchMethod( + Invocation.method( + #deleteTokens, + [tokens], + ), + returnValue: _i6.Future>.value(<_i7.Token>[]), + returnValueForMissingStub: + _i6.Future>.value(<_i7.Token>[]), + ) as _i6.Future>); +} + +/// A class which mocks [SettingsRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSettingsRepository extends _i1.Mock + implements _i8.SettingsRepository { + @override + _i6.Future saveSettings(_i2.SettingsState? settings) => + (super.noSuchMethod( + Invocation.method( + #saveSettings, + [settings], + ), + returnValue: _i6.Future.value(false), + returnValueForMissingStub: _i6.Future.value(false), + ) as _i6.Future); + + @override + _i6.Future<_i2.SettingsState> loadSettings() => (super.noSuchMethod( + Invocation.method( + #loadSettings, + [], + ), + returnValue: _i6.Future<_i2.SettingsState>.value(_FakeSettingsState_0( + this, + Invocation.method( + #loadSettings, + [], + ), + )), + returnValueForMissingStub: + _i6.Future<_i2.SettingsState>.value(_FakeSettingsState_0( + this, + Invocation.method( + #loadSettings, + [], + ), + )), + ) as _i6.Future<_i2.SettingsState>); +} + +/// A class which mocks [TokenFolderRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTokenFolderRepository extends _i1.Mock + implements _i9.TokenFolderRepository { + @override + _i6.Future> saveOrReplaceFolders( + List<_i10.TokenFolder>? folders) => + (super.noSuchMethod( + Invocation.method( + #saveOrReplaceFolders, + [folders], + ), + returnValue: + _i6.Future>.value(<_i10.TokenFolder>[]), + returnValueForMissingStub: + _i6.Future>.value(<_i10.TokenFolder>[]), + ) as _i6.Future>); + + @override + _i6.Future> loadFolders() => (super.noSuchMethod( + Invocation.method( + #loadFolders, + [], + ), + returnValue: + _i6.Future>.value(<_i10.TokenFolder>[]), + returnValueForMissingStub: + _i6.Future>.value(<_i10.TokenFolder>[]), + ) as _i6.Future>); +} + +/// A class which mocks [PrivacyIdeaIOClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPrivacyIdeaIOClient extends _i1.Mock + implements _i11.PrivacyIdeaIOClient { + @override + _i6.Future triggerNetworkAccessPermission({ + required Uri? url, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #triggerNetworkAccessPermission, + [], + { + #url: url, + #sslVerify: sslVerify, + }, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future<_i3.Response> doPost({ + required Uri? url, + required Map? body, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + )), + returnValueForMissingStub: + _i6.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + )), + ) as _i6.Future<_i3.Response>); + + @override + _i6.Future<_i3.Response> doGet({ + required Uri? url, + required Map? parameters, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + )), + returnValueForMissingStub: + _i6.Future<_i3.Response>.value(_FakeResponse_1( + this, + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + )), + ) as _i6.Future<_i3.Response>); +} + +/// A class which mocks [QrParser]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockQrParser extends _i1.Mock implements _i12.QrParser { + @override + Map parseQRCodeToMap(String? uriAsString) => + (super.noSuchMethod( + Invocation.method( + #parseQRCodeToMap, + [uriAsString], + ), + returnValue: {}, + returnValueForMissingStub: {}, + ) as Map); + + @override + bool is2StepURI(Uri? uri) => (super.noSuchMethod( + Invocation.method( + #is2StepURI, + [uri], + ), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); +} + +/// A class which mocks [RsaUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRsaUtils extends _i1.Mock implements _i13.RsaUtils { + @override + _i4.RSAPublicKey deserializeRSAPublicKeyPKCS1(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + returnValue: _FakeRSAPublicKey_2( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + ), + returnValueForMissingStub: _FakeRSAPublicKey_2( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + ), + ) as _i4.RSAPublicKey); + + @override + String serializeRSAPublicKeyPKCS1(_i4.RSAPublicKey? publicKey) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPublicKeyPKCS1, + [publicKey], + ), + returnValue: '', + returnValueForMissingStub: '', + ) as String); + + @override + _i4.RSAPublicKey deserializeRSAPublicKeyPKCS8(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + returnValue: _FakeRSAPublicKey_2( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + ), + returnValueForMissingStub: _FakeRSAPublicKey_2( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + ), + ) as _i4.RSAPublicKey); + + @override + String serializeRSAPublicKeyPKCS8(_i4.RSAPublicKey? key) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPublicKeyPKCS8, + [key], + ), + returnValue: '', + returnValueForMissingStub: '', + ) as String); + + @override + String serializeRSAPrivateKeyPKCS1(_i4.RSAPrivateKey? key) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPrivateKeyPKCS1, + [key], + ), + returnValue: '', + returnValueForMissingStub: '', + ) as String); + + @override + _i4.RSAPrivateKey deserializeRSAPrivateKeyPKCS1(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + returnValue: _FakeRSAPrivateKey_3( + this, + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + ), + returnValueForMissingStub: _FakeRSAPrivateKey_3( + this, + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + ), + ) as _i4.RSAPrivateKey); + + @override + bool verifyRSASignature( + _i4.RSAPublicKey? publicKey, + _i14.Uint8List? signedMessage, + _i14.Uint8List? signature, + ) => + (super.noSuchMethod( + Invocation.method( + #verifyRSASignature, + [ + publicKey, + signedMessage, + signature, + ], + ), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + + @override + _i6.Future trySignWithToken( + _i15.PushToken? token, + String? message, + ) => + (super.noSuchMethod( + Invocation.method( + #trySignWithToken, + [ + token, + message, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future< + _i4.AsymmetricKeyPair<_i4.RSAPublicKey, + _i4.RSAPrivateKey>> generateRSAKeyPair() => (super.noSuchMethod( + Invocation.method( + #generateRSAKeyPair, + [], + ), + returnValue: _i6.Future< + _i4 + .AsymmetricKeyPair<_i4.RSAPublicKey, _i4.RSAPrivateKey>>.value( + _FakeAsymmetricKeyPair_4<_i4.RSAPublicKey, _i4.RSAPrivateKey>( + this, + Invocation.method( + #generateRSAKeyPair, + [], + ), + )), + returnValueForMissingStub: _i6.Future< + _i4 + .AsymmetricKeyPair<_i4.RSAPublicKey, _i4.RSAPrivateKey>>.value( + _FakeAsymmetricKeyPair_4<_i4.RSAPublicKey, _i4.RSAPrivateKey>( + this, + Invocation.method( + #generateRSAKeyPair, + [], + ), + )), + ) as _i6 + .Future<_i4.AsymmetricKeyPair<_i4.RSAPublicKey, _i4.RSAPrivateKey>>); + + @override + String createBase32Signature( + _i4.RSAPrivateKey? privateKey, + _i14.Uint8List? dataToSign, + ) => + (super.noSuchMethod( + Invocation.method( + #createBase32Signature, + [ + privateKey, + dataToSign, + ], + ), + returnValue: '', + returnValueForMissingStub: '', + ) as String); + + @override + _i14.Uint8List createRSASignature( + _i4.RSAPrivateKey? privateKey, + _i14.Uint8List? dataToSign, + ) => + (super.noSuchMethod( + Invocation.method( + #createRSASignature, + [ + privateKey, + dataToSign, + ], + ), + returnValue: _i14.Uint8List(0), + returnValueForMissingStub: _i14.Uint8List(0), + ) as _i14.Uint8List); +} + +/// A class which mocks [FirebaseUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseUtils extends _i1.Mock implements _i16.FirebaseUtils { + @override + _i6.Future initFirebase({ + required _i6.Future Function(_i17.RemoteMessage)? foregroundHandler, + required _i6.Future Function(_i17.RemoteMessage)? backgroundHandler, + required void Function(String?)? updateFirebaseToken, + }) => + (super.noSuchMethod( + Invocation.method( + #initFirebase, + [], + { + #foregroundHandler: foregroundHandler, + #backgroundHandler: backgroundHandler, + #updateFirebaseToken: updateFirebaseToken, + }, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future getFBToken() => (super.noSuchMethod( + Invocation.method( + #getFBToken, + [], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); +} diff --git a/test/unit_test/model/states_test/settings_state_test.dart b/test/unit_test/model/states_test/settings_state_test.dart new file mode 100644 index 000000000..377690f78 --- /dev/null +++ b/test/unit_test/model/states_test/settings_state_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; + +void main() { + _testSettingsState(); +} + +void _testSettingsState() { + group('SettingsState', () { + final state = SettingsState( + isFirstRun: true, + showGuideOnStart: true, + hideOpts: true, + enablePolling: true, + crashReportRecipients: {'test'}, + localePreference: const Locale('en'), + useSystemLocale: true, + verboseLogging: true, + ); + test('constructor', () { + expect(state.isFirstRun, true); + expect(state.showGuideOnStart, true); + expect(state.hideOpts, true); + expect(state.enablePolling, true); + expect(state.crashReportRecipients, {'test'}); + expect(state.localePreference.toLanguageTag(), const Locale('en').toLanguageTag()); + expect(state.useSystemLocale, true); + expect(state.verboseLogging, true); + }); + test('copyWith', () { + final newState = state.copyWith( + isFirstRun: false, + showGuideOnStart: false, + hideOpts: false, + enablePolling: false, + crashReportRecipients: {'test2'}, + localePreference: const Locale('de'), + useSystemLocale: false, + verboseLogging: false, + ); + expect(state.isFirstRun, true); + expect(state.showGuideOnStart, true); + expect(state.hideOpts, true); + expect(state.enablePolling, true); + expect(state.crashReportRecipients, {'test'}); + expect(state.localePreference.toLanguageTag(), const Locale('en').toLanguageTag()); + expect(state.useSystemLocale, true); + expect(state.verboseLogging, true); + expect(newState.isFirstRun, false); + expect(newState.showGuideOnStart, false); + expect(newState.hideOpts, false); + expect(newState.enablePolling, false); + expect(newState.crashReportRecipients, {'test2'}); + expect(newState.localePreference.toLanguageTag(), const Locale('de').toLanguageTag()); + expect(newState.useSystemLocale, false); + expect(newState.verboseLogging, false); + }); + test('encodeLocale/decodeLocale', () { + const locale = Locale('en'); + final encodedLocale = SettingsState.encodeLocale(locale); + final decodedLocale = SettingsState.decodeLocale(encodedLocale); + expect(locale.toLanguageTag(), decodedLocale.toLanguageTag()); + }); + }); +} diff --git a/test/unit_test/model/states_test/token_folder_state_test.dart b/test/unit_test/model/states_test/token_folder_state_test.dart new file mode 100644 index 000000000..2c08bad92 --- /dev/null +++ b/test/unit_test/model/states_test/token_folder_state_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/states/token_folder_state.dart'; +import 'package:privacyidea_authenticator/model/token_folder.dart'; + +void main() { + _testTokenFolderState(); +} + +void _testTokenFolderState() { + group('TokenFolderState', () { + const state = TokenFolderState(folders: [TokenFolder(label: 'label', folderId: 1)]); + test('constructor', () { + expect(state.folders.first.label, 'label'); + expect(state.folders.first.folderId, 1); + }); + test('withFolder', () { + final newState = state.withFolder('newFolder'); + expect(state.folders.first.label, 'label'); + expect(state.folders.first.folderId, 1); + expect(newState.folders.length, 2); + expect(newState.folders.first.label, 'label'); + expect(newState.folders.first.folderId, 1); + expect(newState.folders.last.label, 'newFolder'); + expect(newState.folders.last.folderId, 2); + }); + test('withUpdated', () { + final newState = state.withUpdated([const TokenFolder(label: 'labelUpdated', folderId: 1)]); + expect(state.folders.first.label, 'label'); + expect(state.folders.first.folderId, 1); + expect(newState.folders.length, 1); + expect(newState.folders.first.label, 'labelUpdated'); + expect(newState.folders.first.folderId, 1); + }); + test('withoutFolder', () { + final newState = state.withoutFolder(const TokenFolder(label: 'label', folderId: 1)); + expect(state.folders.first.label, 'label'); + expect(state.folders.first.folderId, 1); + expect(newState.folders.length, 0); + }); + }); +} diff --git a/test/unit_test/model/states_test/token_state_test.dart b/test/unit_test/model/states_test/token_state_test.dart new file mode 100644 index 000000000..d7439a6a1 --- /dev/null +++ b/test/unit_test/model/states_test/token_state_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/states/token_state.dart'; +import 'package:privacyidea_authenticator/model/tokens/token.dart'; +import 'package:mockito/mockito.dart'; + +// ignore: must_be_immutable +class _TokenMock extends Mock implements Token { + @override + final String label; + @override + final String id; + _TokenMock({required this.id, this.label = 'label'}); +} + +void main() { + _testTokenState(); +} + +void _testTokenState() { + group('TokenState', () { + test('constructor', () { + final state = TokenState(tokens: [_TokenMock(id: 'id')]); + expect(state.tokens.first, isA<_TokenMock>()); + }); + test('repaceList', () { + final state = TokenState(tokens: [_TokenMock(id: 'id')]); + final newState = state.repaceList(tokens: [_TokenMock(id: 'idCopy')]); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect((newState.tokens.first as _TokenMock).id, 'idCopy'); + }); + test('withToken', () { + final state = TokenState(tokens: [_TokenMock(id: 'id')]); + final newState = state.withToken(_TokenMock(id: 'newid')); + expect(state.tokens.length, 1); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect(newState.tokens.length, 2); + expect((newState.tokens.first as _TokenMock).id, 'id'); + expect((newState.tokens.last as _TokenMock).id, 'newid'); + }); + test('withTokens', () { + final state = TokenState(tokens: [_TokenMock(id: 'id')]); + final newState = state.withTokens([_TokenMock(id: 'newid'), _TokenMock(id: 'newid2')]); + expect(state.tokens.length, 1); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect(newState.tokens.length, 3); + expect((newState.tokens[0] as _TokenMock).id, 'id'); + expect((newState.tokens[1] as _TokenMock).id, 'newid'); + expect((newState.tokens[2] as _TokenMock).id, 'newid2'); + }); + test('withoutToken', () { + final state = TokenState(tokens: [_TokenMock(id: 'id'), _TokenMock(id: 'id2')]); + final newState = state.withoutToken(_TokenMock(id: 'id')); + expect(state.tokens.length, 2); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect((state.tokens.last as _TokenMock).id, 'id2'); + expect(newState.tokens.length, 1); + expect((newState.tokens.first as _TokenMock).id, 'id2'); + }); + test('withoutTokens', () { + final state = TokenState(tokens: [_TokenMock(id: 'id'), _TokenMock(id: 'id2'), _TokenMock(id: 'id3')]); + final newState = state.withoutTokens([_TokenMock(id: 'id'), _TokenMock(id: 'id2')]); + expect(state.tokens.length, 3); + expect((state.tokens[0] as _TokenMock).id, 'id'); + expect((state.tokens[1] as _TokenMock).id, 'id2'); + expect((state.tokens[2] as _TokenMock).id, 'id3'); + expect(newState.tokens.length, 1); + expect((newState.tokens.first as _TokenMock).id, 'id3'); + }); + group('addOrReplaceToken', () { + test('existing id', () { + final state = TokenState(tokens: [_TokenMock(id: 'id'), _TokenMock(id: 'id2')]); + final newState = state.addOrReplaceToken(_TokenMock(id: 'id', label: 'labelUpdated')); + expect(state.tokens.length, 2); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect((state.tokens.last as _TokenMock).id, 'id2'); + expect(newState.tokens.length, 2); + expect((newState.tokens.first as _TokenMock).id, 'id'); + expect((newState.tokens.first as _TokenMock).label, 'labelUpdated'); + }); + test('new id', () { + final state = TokenState(tokens: [_TokenMock(id: 'id'), _TokenMock(id: 'id2')]); + final newState = state.addOrReplaceToken(_TokenMock(id: 'newId', label: 'labelUpdated')); + expect(state.tokens.length, 2); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect((state.tokens.last as _TokenMock).id, 'id2'); + expect(newState.tokens.length, 3); + expect((newState.tokens.last as _TokenMock).id, 'newId'); + expect((newState.tokens.last as _TokenMock).label, 'labelUpdated'); + }); + }); + + test('addOrReplaceTokens', () { + final state = TokenState(tokens: [_TokenMock(id: 'id'), _TokenMock(id: 'id2')]); + final newState = state.addOrReplaceTokens([_TokenMock(id: 'id', label: 'labelUpdated'), _TokenMock(id: 'id3')]); + expect(state.tokens.length, 2); + expect((state.tokens.first as _TokenMock).id, 'id'); + expect((state.tokens.last as _TokenMock).id, 'id2'); + expect(newState.tokens.length, 3); + expect((newState.tokens[0] as _TokenMock).id, 'id'); + expect((newState.tokens[0] as _TokenMock).label, 'labelUpdated'); + expect((newState.tokens[1] as _TokenMock).id, 'id2'); + expect((newState.tokens[2] as _TokenMock).id, 'id3'); + }); + }); +} diff --git a/test/unit_test/model/token_folder_test.dart b/test/unit_test/model/token_folder_test.dart new file mode 100644 index 000000000..a9a7a672f --- /dev/null +++ b/test/unit_test/model/token_folder_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/token_folder.dart'; + +void main() { + _testTokenFolder(); +} + +void _testTokenFolder() { + group('TokenFolder', () { + test('constructor', () { + const folder1 = TokenFolder(label: 'test', folderId: 1, isExpanded: true, isLocked: false, sortIndex: 0); + expect(folder1.label, 'test'); + expect(folder1.folderId, 1); + expect(folder1.isExpanded, true); + expect(folder1.isLocked, false); + expect(folder1.sortIndex, 0); + const folder2 = TokenFolder(label: 'test2', folderId: 2, isExpanded: false, isLocked: true, sortIndex: 1); + expect(folder2.label, 'test2'); + expect(folder2.folderId, 2); + expect(folder2.isExpanded, false); + expect(folder2.isLocked, true); + expect(folder2.sortIndex, 1); + }); + test('copyWith', () { + const folder = TokenFolder(label: 'test', folderId: 1, isExpanded: true, isLocked: false, sortIndex: 0); + final folderCopy = folder.copyWith(label: 'test2', folderId: 2, isExpanded: false, isLocked: true, sortIndex: 1); + expect(folderCopy.label, 'test2'); + expect(folderCopy.folderId, 2); + expect(folderCopy.isExpanded, false); + expect(folderCopy.isLocked, true); + expect(folderCopy.sortIndex, 1); + }); + test('fromJson', () { + final folder1 = TokenFolder.fromJson(const { + 'label': 'test', + 'folderId': 1, + 'isExpanded': true, + 'isLocked': true, + 'sortIndex': 0, + }); + expect(folder1.label, 'test'); + expect(folder1.folderId, 1); + expect(folder1.isExpanded, false); + expect(folder1.isLocked, true); + expect(folder1.sortIndex, 0); + final folder2 = TokenFolder.fromJson(const { + 'label': 'test2', + 'folderId': 2, + 'isExpanded': true, + 'isLocked': false, + 'sortIndex': 1, + }); + expect(folder2.label, 'test2'); + expect(folder2.folderId, 2); + expect(folder2.isExpanded, true); + expect(folder2.isLocked, false); + expect(folder2.sortIndex, 1); + }); + }); +} diff --git a/test/unit_test/model/token_test/day_password_test.dart b/test/unit_test/model/token_test/day_password_test.dart new file mode 100644 index 000000000..5dfbec04b --- /dev/null +++ b/test/unit_test/model/token_test/day_password_test.dart @@ -0,0 +1,419 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/tokens/day_password_token.dart'; +import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; + +void main() { + _testDayPasswordToken(); +} + +void _testDayPasswordToken() { + group('Day password creation/method', () { + final dayPasswordToken = DayPasswordToken( + period: const Duration(hours: 24), + viewMode: DayPasswordTokenViewMode.VALIDUNTIL, + label: 'label', + issuer: 'issuer', + id: 'id', + algorithm: Algorithms.SHA1, + digits: 6, + secret: 'secret', + pin: true, + tokenImage: 'example.png', + sortIndex: 0, + isLocked: false, + folderId: 0, + ); + test('constructor', () { + expect(dayPasswordToken.period, const Duration(hours: 24)); + expect(dayPasswordToken.viewMode, DayPasswordTokenViewMode.VALIDUNTIL); + expect(dayPasswordToken.label, 'label'); + expect(dayPasswordToken.issuer, 'issuer'); + expect(dayPasswordToken.id, 'id'); + expect(dayPasswordToken.algorithm, Algorithms.SHA1); + expect(dayPasswordToken.digits, 6); + expect(dayPasswordToken.secret, 'secret'); + expect(dayPasswordToken.pin, true); + expect(dayPasswordToken.tokenImage, 'example.png'); + expect(dayPasswordToken.sortIndex, 0); + expect(dayPasswordToken.isLocked, true); + expect(dayPasswordToken.folderId, 0); + }); + }); + group('Calculate day password values', () { + // Basicly the day password is a HOTP token but the counter is calculated based on the current time. + // So we can test day password token by comparing its OTP value with a HOTP value with the same counter. + // as we know the HOTP token works, we can assume the day password token works as well when they have the same otp value. + + group('different periods 6 digits', () { + const digits = 6; + test('1h period', () { + const period = Duration(hours: 1); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('12h period', () { + const period = Duration(hours: 12); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('24h period', () { + const period = Duration(hours: 24); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('3 days period', () { + const period = Duration(days: 3); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('28 days period', () { + const period = Duration(days: 28); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + }); + group('different periods 8 digits', () { + const digits = 8; + test('1h period', () { + const period = Duration(hours: 1); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('12h period', () { + const period = Duration(hours: 12); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('24h period', () { + const period = Duration(hours: 24); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('3 days period', () { + const period = Duration(days: 3); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('28 days period', () { + const period = Duration(days: 28); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + }); + group('different algorithms 6 digits', () { + const digits = 6; + const period = Duration(hours: 24); + test('SHA1 algorithm', () { + const algorithm = Algorithms.SHA1; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('SHA256 algorithm', () { + const algorithm = Algorithms.SHA256; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('SHA512 algorithm', () { + const algorithm = Algorithms.SHA512; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + }); + group('different algorithms 8 digits', () { + const digits = 8; + const period = Duration(hours: 24); + test('SHA1 algorithm', () { + const algorithm = Algorithms.SHA1; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('SHA256 algorithm', () { + const algorithm = Algorithms.SHA256; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + test('SHA512 algorithm', () { + const algorithm = Algorithms.SHA512; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final dayPassword1h = DayPasswordToken( + label: '', + issuer: '', + id: '', + period: period, + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(hotpToken.otpValue, dayPassword1h.otpValue); + }); + }); + }); +} diff --git a/test/unit_test/model/token_test/hotp_token_test.dart b/test/unit_test/model/token_test/hotp_token_test.dart new file mode 100644 index 000000000..39e2098de --- /dev/null +++ b/test/unit_test/model/token_test/hotp_token_test.dart @@ -0,0 +1,333 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; + +void main() { + _testHotpToken(); +} + +void _testHotpToken() { + group('HOTP Token creation/method', () { + final hotpToken = HOTPToken( + counter: 1, + label: 'label', + issuer: 'issuer', + id: 'id', + algorithm: Algorithms.SHA1, + digits: 6, + secret: 'secret', + type: 'type', + pin: true, + tokenImage: 'example.png', + sortIndex: 0, + isLocked: false, + folderId: 0, + ); + test('constructor', () { + expect(hotpToken.counter, 1); + expect(hotpToken.label, 'label'); + expect(hotpToken.issuer, 'issuer'); + expect(hotpToken.id, 'id'); + expect(hotpToken.algorithm, Algorithms.SHA1); + expect(hotpToken.digits, 6); + expect(hotpToken.secret, 'secret'); + expect(hotpToken.type, 'HOTP'); + expect(hotpToken.pin, true); + expect(hotpToken.tokenImage, 'example.png'); + expect(hotpToken.sortIndex, 0); + expect(hotpToken.isLocked, true); + expect(hotpToken.folderId, 0); + }); + test('withNextCounter', () { + final withNextCounter = hotpToken.withNextCounter(); + expect(withNextCounter.counter, 2); + }); + test('copyWith', () { + final hotpCopy = hotpToken.copyWith( + counter: 5, + label: 'labelCopy', + issuer: 'issuerCopy', + id: 'idCopy', + algorithm: Algorithms.SHA256, + digits: 8, + secret: 'secretCopy', + pin: false, + tokenImage: 'exampleCopy.png', + sortIndex: 1, + isLocked: true, + folderId: () => 1, + ); + expect(hotpCopy.counter, 5); + expect(hotpCopy.label, 'labelCopy'); + expect(hotpCopy.issuer, 'issuerCopy'); + expect(hotpCopy.id, 'idCopy'); + expect(hotpCopy.algorithm, Algorithms.SHA256); + expect(hotpCopy.digits, 8); + expect(hotpCopy.secret, 'secretCopy'); + expect(hotpCopy.type, 'HOTP'); + expect(hotpCopy.pin, false); + expect(hotpCopy.tokenImage, 'exampleCopy.png'); + expect(hotpCopy.sortIndex, 1); + expect(hotpCopy.isLocked, true); + expect(hotpCopy.folderId, 1); + }); + group('fromUriMap', () { + test('with full map', () { + final uriMap = { + 'URI_COUNTER': 10, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_SECRET': Uint8List.fromList(utf8.encode('secret')), + 'URI_DIGITS': 6, + 'URI_TYPE': 'HOTP', + 'URI_PIN': true, + 'URI_IMAGE': 'example.png', + }; + final hotpFromUriMap = HOTPToken.fromUriMap(uriMap); + expect(hotpFromUriMap.counter, 10); + expect(hotpFromUriMap.label, 'label'); + expect(hotpFromUriMap.issuer, 'issuer'); + expect(hotpFromUriMap.algorithm, Algorithms.SHA1); + expect(hotpFromUriMap.secret, 'ONSWG4TFOQ======'); + expect(hotpFromUriMap.digits, 6); + expect(hotpFromUriMap.type, 'HOTP'); + expect(hotpFromUriMap.pin, true); + expect(hotpFromUriMap.tokenImage, 'example.png'); + }); + test('without secret', () { + final uriMap = { + 'URI_COUNTER': 10, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_DIGITS': 6, + 'URI_TYPE': 'HOTP', + 'URI_PIN': true, + 'URI_IMAGE': 'example.png', + }; + expect(() => HOTPToken.fromUriMap(uriMap), throwsArgumentError); + }); + test('digits is zero', () { + final uriMap = { + 'URI_COUNTER': 10, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_SECRET': Uint8List.fromList(utf8.encode('secret')), + 'URI_DIGITS': 0, + 'URI_TYPE': 'HOTP', + 'URI_PIN': true, + 'URI_IMAGE': 'example.png', + }; + expect(() => HOTPToken.fromUriMap(uriMap), throwsArgumentError); + }); + test('with empty map', () { + final uriMap = {}; + expect(() => HOTPToken.fromUriMap(uriMap), throwsArgumentError); + }); + }); + }); + group('Calculate hotp values', () { + group('different couters 6 digits', () { + // We need to use different tokens here, because simply incrementing the + // counter between all method calls leads to a race condition + test('OTP for counter == 0', () { + HOTPToken token0 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 6, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token0.otpValue, '814628'); + }); + + test('OTP for counter == 1', () { + HOTPToken token1 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 6, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 1, + ); + expect(token1.otpValue, '533881'); + }); + + test('OTP for counter == 2', () { + HOTPToken token2 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 6, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 2, + ); + expect(token2.otpValue, '720111'); + }); + + test('OTP for counter == 8', () { + HOTPToken token8 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 6, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 8, + ); + expect(token8.otpValue, '963685'); + }); + }); + group('different couters 8 digits', () { + // We need to use different tokens here, because simply incrementing the + // counter between all method calls leads to a race condition + + test('OTP for counter == 0', () { + HOTPToken token0 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 8, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token0.otpValue, '31814628'); + }); + + test('OTP for counter == 1', () { + HOTPToken token1 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 8, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 1, + ); + expect(token1.otpValue, '28533881'); + }); + + test('OTP for counter == 2', () { + HOTPToken token2 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 8, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 2, + ); + expect(token2.otpValue, '31720111'); + }); + + test('OTP for counter == 8', () { + HOTPToken token8 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 8, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: 8, + ); + expect(token8.otpValue, '15963685'); + }); + }); + group('different algorithms 6 digits', () { + // We need to use different tokens here, because simply incrementing the + // counter between all method calls leads to a race condition + + test('OTP for sha1', () { + HOTPToken token0 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 6, + secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token0.otpValue, '292574'); + }); + + test('OTP for sha256', () { + HOTPToken token1 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA256, + digits: 6, + secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token1.otpValue, '203782'); + }); + + test('OTP for sha512', () { + HOTPToken token2 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA512, + digits: 6, + secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token2.otpValue, '636350'); + }); + }); + group('different algorithms 8 digits', () { + // We need to use different tokens here, because simply incrementing the + // counter between all method calls leads to a race condition + test('OTP for sha1', () { + HOTPToken token0 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: 8, + secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token0.otpValue, '25292574'); + }); + + test('OTP for sha256', () { + HOTPToken token1 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA256, + digits: 8, + secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token1.otpValue, '25203782'); + }); + + test('OTP for sha512', () { + HOTPToken token2 = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA512, + digits: 8, + secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), + counter: 0, + ); + expect(token2.otpValue, '99636350'); + }); + }); + }); +} diff --git a/test/unit_test/model/token_test/push_token_test.dart b/test/unit_test/model/token_test/push_token_test.dart new file mode 100644 index 000000000..9d1695c4a --- /dev/null +++ b/test/unit_test/model/token_test/push_token_test.dart @@ -0,0 +1,298 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/push_request.dart'; +import 'package:privacyidea_authenticator/model/push_request_queue.dart'; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; +import 'package:privacyidea_authenticator/utils/custom_int_buffer.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; + +void main() { + _testPushToken(); +} + +void _testPushToken() { + group('Push Token creation/method', () { + final pr = PushRequest( + title: 'title', + question: 'question', + uri: Uri.parse('http://www.example.com'), + nonce: 'nonce', + sslVerify: false, + id: 0, + expirationDate: DateTime(2017, 9, 7, 17, 30), + ); + final pushToken = PushToken( + serial: 'serial', + expirationDate: DateTime(2017, 9, 7, 17, 30), + label: 'label', + issuer: 'issuer', + id: 'id', + sslVerify: true, + enrollmentCredentials: 'enrollmentCredentials', + url: Uri.parse('http://www.example.com'), + publicServerKey: 'publicServerKey', + publicTokenKey: 'publicTokenKey', + privateTokenKey: 'privateTokenKey', + isRolledOut: true, + rolloutState: PushTokenRollOutState.rolloutNotStarted, + pushRequests: PushRequestQueue(), + knownPushRequests: CustomIntBuffer(), + type: 'type', + sortIndex: 0, + tokenImage: 'example.png', + folderId: 0, + isLocked: true, + pin: true, + ); + test('constructor', () { + expect(pushToken.serial, 'serial'); + expect(pushToken.expirationDate, DateTime(2017, 9, 7, 17, 30)); + expect(pushToken.label, 'label'); + expect(pushToken.issuer, 'issuer'); + expect(pushToken.id, 'id'); + expect(pushToken.sslVerify, true); + expect(pushToken.enrollmentCredentials, 'enrollmentCredentials'); + expect(pushToken.url, Uri.parse('http://www.example.com')); + expect(pushToken.publicServerKey, 'publicServerKey'); + expect(pushToken.publicTokenKey, 'publicTokenKey'); + expect(pushToken.privateTokenKey, 'privateTokenKey'); + expect(pushToken.isRolledOut, true); + expect(pushToken.rolloutState, PushTokenRollOutState.rolloutNotStarted); + expect(pushToken.pushRequests, PushRequestQueue()); + expect(pushToken.knownPushRequests.list, CustomIntBuffer().list); + expect(pushToken.type, 'PIPUSH'); + expect(pushToken.sortIndex, 0); + expect(pushToken.tokenImage, 'example.png'); + expect(pushToken.folderId, 0); + expect(pushToken.isLocked, true); + expect(pushToken.pin, true); + }); + test('copyWith', () { + final copy = pushToken.copyWith( + serial: 'serialCopy', + expirationDate: DateTime(2016, 8, 6, 16, 29), + label: 'labelCopy', + issuer: 'issuerCopy', + id: 'idCopy', + sslVerify: false, + enrollmentCredentials: 'enrollmentCredentialsCopy', + url: Uri.parse('http://www.example.com/copy'), + publicServerKey: 'publicServerKeyCopy', + publicTokenKey: 'publicTokenKeyCopy', + privateTokenKey: 'privateTokenKeyCopy', + isRolledOut: false, + rolloutState: PushTokenRollOutState.rolloutComplete, + pushRequests: PushRequestQueue()..add(pr), + knownPushRequests: CustomIntBuffer()..put(0), + sortIndex: 1, + tokenImage: 'exampleCopy.png', + folderId: () => 1, + isLocked: false, + pin: false, + ); + expect(copy.serial, 'serialCopy'); + expect(copy.expirationDate, DateTime(2016, 8, 6, 16, 29)); + expect(copy.label, 'labelCopy'); + expect(copy.issuer, 'issuerCopy'); + expect(copy.id, 'idCopy'); + expect(copy.sslVerify, false); + expect(copy.enrollmentCredentials, 'enrollmentCredentialsCopy'); + expect(copy.url, Uri.parse('http://www.example.com/copy')); + expect(copy.publicServerKey, 'publicServerKeyCopy'); + expect(copy.publicTokenKey, 'publicTokenKeyCopy'); + expect(copy.privateTokenKey, 'privateTokenKeyCopy'); + expect(copy.isRolledOut, false); + expect(copy.rolloutState, PushTokenRollOutState.rolloutComplete); + expect(copy.pushRequests.list, [pr]); + expect(copy.knownPushRequests.list, [0]); + expect(copy.sortIndex, 1); + expect(copy.tokenImage, 'exampleCopy.png'); + expect(copy.folderId, 1); + expect(copy.isLocked, false); + expect(copy.pin, false); + }); + test('withPushRequest', () { + final tokenWithPr = PushToken( + serial: 'serial', + expirationDate: DateTime(2017, 9, 7, 17, 30), + label: 'label', + issuer: 'issuer', + id: 'id', + sslVerify: true, + enrollmentCredentials: 'enrollmentCredentials', + url: Uri.parse('http://www.example.com'), + publicServerKey: 'publicServerKey', + publicTokenKey: 'publicTokenKey', + privateTokenKey: 'privateTokenKey', + isRolledOut: true, + rolloutState: PushTokenRollOutState.rolloutNotStarted, + pushRequests: PushRequestQueue(), + knownPushRequests: CustomIntBuffer(), + type: 'type', + sortIndex: 0, + tokenImage: 'example.png', + folderId: 0, + isLocked: true, + pin: true, + ).withPushRequest(pr); + expect(tokenWithPr.pushRequests.list, [pr]); + expect(tokenWithPr.knownPushRequests.list, [0]); + }); + test('withoutPushrequest', () { + final tokenWithPr = PushToken( + serial: 'serial', + expirationDate: DateTime(2017, 9, 7, 17, 30), + label: 'label', + issuer: 'issuer', + id: 'id', + sslVerify: true, + enrollmentCredentials: 'enrollmentCredentials', + url: Uri.parse('http://www.example.com'), + publicServerKey: 'publicServerKey', + publicTokenKey: 'publicTokenKey', + privateTokenKey: 'privateTokenKey', + isRolledOut: true, + rolloutState: PushTokenRollOutState.rolloutNotStarted, + pushRequests: PushRequestQueue()..add(pr), + knownPushRequests: CustomIntBuffer()..put(0), + type: 'type', + sortIndex: 0, + tokenImage: 'example.png', + folderId: 0, + isLocked: true, + pin: true, + ); + final tokenWithoutPr = tokenWithPr.withoutPushRequest(pr); + expect(tokenWithoutPr.pushRequests.list, []); + expect(tokenWithoutPr.knownPushRequests.list, [0]); + }); + test('knowsRequestWithId', () { + final tokenWithPr = PushToken( + serial: 'serial', + expirationDate: DateTime(2017, 9, 7, 17, 30), + label: 'label', + issuer: 'issuer', + id: 'id', + sslVerify: true, + enrollmentCredentials: 'enrollmentCredentials', + url: Uri.parse('http://www.example.com'), + publicServerKey: 'publicServerKey', + publicTokenKey: 'publicTokenKey', + privateTokenKey: 'privateTokenKey', + isRolledOut: true, + rolloutState: PushTokenRollOutState.rolloutNotStarted, + pushRequests: PushRequestQueue()..add(pr), + knownPushRequests: CustomIntBuffer()..put(0), + type: 'type', + sortIndex: 0, + tokenImage: 'example.png', + folderId: 0, + isLocked: true, + pin: true, + ); + expect(tokenWithPr.knowsRequestWithId(0), true); + }); + test('fromJson', () { + final json = { + "label": "label", + "issuer": "issuer", + "id": "id", + "isLocked": true, + "pin": true, + "tokenImage": "example.png", + "folderId": 0, + "sortIndex": 0, + "type": "type", + "expirationDate": "2017-09-07T17:30:00.000", + "serial": "serial", + "sslVerify": true, + "enrollmentCredentials": "enrollmentCredentials", + "url": "http://www.example.com", + "isRolledOut": true, + "rolloutState": "generatingRSAKeyPair", + "publicServerKey": "publicServerKey", + "privateTokenKey": "privateTokenKey", + "publicTokenKey": "publicTokenKey", + "pushRequests": {"list": []}, + "knownPushRequests": {"list": []} + }; + final token = PushToken.fromJson(json); + expect(token.label, 'label'); + expect(token.issuer, 'issuer'); + expect(token.id, 'id'); + expect(token.isLocked, true); + expect(token.pin, true); + expect(token.tokenImage, 'example.png'); + expect(token.folderId, 0); + expect(token.sortIndex, 0); + expect(token.type, 'PIPUSH'); + expect(token.expirationDate.toString(), DateTime(2017, 9, 7, 17, 30).toString()); + expect(token.serial, 'serial'); + expect(token.sslVerify, true); + expect(token.enrollmentCredentials, 'enrollmentCredentials'); + expect(token.url, Uri.parse('http://www.example.com')); + expect(token.isRolledOut, true); + expect(token.rolloutState, + PushTokenRollOutState.generatingRSAKeyPairFailed); // When loading from json, an processing state should be converted to a failed state. + expect(token.publicServerKey, 'publicServerKey'); + expect(token.privateTokenKey, 'privateTokenKey'); + expect(token.publicTokenKey, 'publicTokenKey'); + expect(token.pushRequests.list, []); + expect(token.knownPushRequests.list, []); + }); + test('toJson', () { + final tokenJson = pushToken.toJson(); + final json = { + "label": "label", + "issuer": "issuer", + "id": "id", + "isLocked": true, + "pin": true, + "tokenImage": "example.png", + "folderId": 0, + "sortIndex": 0, + "type": "PIPUSH", + "expirationDate": "2017-09-07T17:30:00.000", + "serial": "serial", + "sslVerify": true, + "enrollmentCredentials": "enrollmentCredentials", + "url": "http://www.example.com", + "isRolledOut": true, + "rolloutState": "rolloutNotStarted", + "publicServerKey": "publicServerKey", + "privateTokenKey": "privateTokenKey", + "publicTokenKey": "publicTokenKey", + "pushRequests": {"list": []}, + "knownPushRequests": {"list": []} + }; + expect(jsonEncode(tokenJson), jsonEncode(json)); + }); + group('fromUriMap', () { + test('with full map', () { + final uriMap = { + 'URI_TYPE': 'PIPUSH', + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_SERIAL': 'serial', + 'URI_SSL_VERIFY': false, + 'URI_ENROLLMENT_CREDENTIAL': 'enrollmentCredentials', + 'URI_ROLLOUT_URL': Uri.parse('http://www.example.com'), + 'URI_TTL': 10, + }; + final token = PushToken.fromUriMap(uriMap); + expect(token.type, 'PIPUSH'); + expect(token.label, 'label'); + expect(token.issuer, 'issuer'); + expect(token.serial, 'serial'); + expect(token.sslVerify, false); + expect(token.enrollmentCredentials, 'enrollmentCredentials'); + expect(token.url, Uri.parse('http://www.example.com')); + }); + test('with empty map', () { + final uriMap = {}; + expect(PushToken.fromUriMap(uriMap), isA()); + }); + }); + }); +} diff --git a/test/unit_test/model/token_test/totp_token_test.dart b/test/unit_test/model/token_test/totp_token_test.dart new file mode 100644 index 000000000..dbb5fb7f2 --- /dev/null +++ b/test/unit_test/model/token_test/totp_token_test.dart @@ -0,0 +1,431 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; +import 'package:privacyidea_authenticator/model/tokens/totp_token.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; + +void main() { + _testTotpToken(); +} + +void _testTotpToken() { + group('Calculate TOTP Token values', () { + // Basicly the TOTP token is a HOTP token but the counter is calculated based on the current time. + // So we can test TOTP token by comparing its OTP value with a HOTP value with the same counter. + // as we know the HOTP token works, we can assume the TOTP token works as well when they have the same otp value. + group('TOTP Token creation/method', () { + final totpToken = TOTPToken( + period: 30, + label: 'label', + issuer: 'issuer', + id: 'id', + algorithm: Algorithms.SHA1, + digits: 6, + secret: 'secret', + pin: false, + tokenImage: 'example.png', + sortIndex: 0, + isLocked: false, + folderId: 0, + ); + test('constructor', () { + expect(totpToken.period, 30); + expect(totpToken.label, 'label'); + expect(totpToken.issuer, 'issuer'); + expect(totpToken.id, 'id'); + expect(totpToken.algorithm, Algorithms.SHA1); + expect(totpToken.digits, 6); + expect(totpToken.secret, 'secret'); + expect(totpToken.type, 'TOTP'); + expect(totpToken.pin, false); + expect(totpToken.tokenImage, 'example.png'); + expect(totpToken.sortIndex, 0); + expect(totpToken.isLocked, false); + expect(totpToken.folderId, 0); + }); + test('copyWith', () { + final totpCopy = totpToken.copyWith( + period: 60, + label: 'labelCopy', + issuer: 'issuerCopy', + id: 'idCopy', + algorithm: Algorithms.SHA256, + digits: 8, + secret: 'secretCopy', + pin: true, + tokenImage: 'exampleCopy.png', + sortIndex: 1, + isLocked: true, + folderId: () => 1, + ); + expect(totpCopy.period, 60); + expect(totpCopy.label, 'labelCopy'); + expect(totpCopy.issuer, 'issuerCopy'); + expect(totpCopy.id, 'idCopy'); + expect(totpCopy.algorithm, Algorithms.SHA256); + expect(totpCopy.digits, 8); + expect(totpCopy.secret, 'secretCopy'); + expect(totpCopy.type, 'TOTP'); + expect(totpCopy.pin, true); + expect(totpCopy.tokenImage, 'exampleCopy.png'); + expect(totpCopy.sortIndex, 1); + expect(totpCopy.isLocked, true); + expect(totpCopy.folderId, 1); + }); + group('fromUriMap', () { + test('with full map', () { + final uriMap = { + 'URI_PERIOD': 30, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_DIGITS': 6, + 'URI_SECRET': Uint8List.fromList(utf8.encode('secret')), + 'URI_TYPE': 'totp', + 'URI_PIN': false, + 'URI_IMAGE': 'example.png', + }; + final totpFromUriMap = TOTPToken.fromUriMap(uriMap); + expect(totpFromUriMap.period, 30); + expect(totpFromUriMap.label, 'label'); + expect(totpFromUriMap.issuer, 'issuer'); + expect(totpFromUriMap.algorithm, Algorithms.SHA1); + expect(totpFromUriMap.digits, 6); + expect(totpFromUriMap.secret, 'ONSWG4TFOQ======'); + expect(totpFromUriMap.type, 'TOTP'); + expect(totpFromUriMap.pin, false); + expect(totpFromUriMap.tokenImage, 'example.png'); + }); + test('with missing secret', () { + final uriMap = { + 'URI_PERIOD': 30, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_DIGITS': 6, + 'URI_TYPE': 'totp', + 'URI_PIN': false, + 'URI_IMAGE': 'example.png', + }; + expect(() => TOTPToken.fromUriMap(uriMap), throwsA(isA())); + }); + test('with zero period', () { + final uriMap = { + 'URI_PERIOD': 0, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_DIGITS': 6, + 'URI_SECRET': Uint8List.fromList(utf8.encode('secret')), + 'URI_TYPE': 'totp', + 'URI_PIN': false, + 'URI_IMAGE': 'example.png', + }; + expect(() => TOTPToken.fromUriMap(uriMap), throwsA(isA())); + }); + test('with zero digits', () { + final uriMap = { + 'URI_PERIOD': 30, + 'URI_LABEL': 'label', + 'URI_ISSUER': 'issuer', + 'URI_ALGORITHM': 'SHA1', + 'URI_DIGITS': 0, + 'URI_SECRET': Uint8List.fromList(utf8.encode('secret')), + 'URI_TYPE': 'totp', + 'URI_PIN': false, + 'URI_IMAGE': 'example.png', + }; + expect(() => TOTPToken.fromUriMap(uriMap), throwsA(isA())); + }); + test('with empty map', () { + final uriMap = {}; + expect(() => TOTPToken.fromUriMap(uriMap), throwsA(isA())); + }); + }); + test('fromJson', () { + final totpJson = { + 'period': 11, + 'label': 'label', + 'issuer': 'issuer', + 'id': 'id', + 'algorithm': 'SHA1', + 'digits': 22, + 'secret': 'secret', + 'type': 'totp', + 'pin': true, + 'tokenImage': 'example.png', + 'sortIndex': 33, + 'isLocked': true, + 'folderId': 44, + }; + final totpFromJson = TOTPToken.fromJson(totpJson); + expect(totpFromJson.period, 11); + expect(totpFromJson.label, 'label'); + expect(totpFromJson.issuer, 'issuer'); + expect(totpFromJson.id, 'id'); + expect(totpFromJson.algorithm, Algorithms.SHA1); + expect(totpFromJson.digits, 22); + expect(totpFromJson.secret, 'secret'); + expect(totpFromJson.type, 'TOTP'); + expect(totpFromJson.pin, true); + expect(totpFromJson.tokenImage, 'example.png'); + expect(totpFromJson.sortIndex, 33); + expect(totpFromJson.isLocked, true); + expect(totpFromJson.folderId, 44); + }); + test('toJson', () { + final totpJson = totpToken.toJson(); + expect(totpJson['period'], 30); + expect(totpJson['label'], 'label'); + expect(totpJson['issuer'], 'issuer'); + expect(totpJson['id'], 'id'); + expect(totpJson['algorithm'], 'SHA1'); + expect(totpJson['digits'], 6); + expect(totpJson['secret'], 'secret'); + expect(totpJson['type'], 'TOTP'); + expect(totpJson['pin'], false); + expect(totpJson['tokenImage'], 'example.png'); + expect(totpJson['sortIndex'], 0); + expect(totpJson['isLocked'], false); + expect(totpJson['folderId'], 0); + }); + }); + group('different periods 6 digits', () { + const digits = 6; + test('30s period', () { + const period = Duration(seconds: 30); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + test('60s period', () { + const period = Duration(seconds: 60); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + }); + group('different periods 8 digits', () { + const digits = 8; + test('30s period', () { + const period = Duration(seconds: 30); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + test('60s period', () { + const period = Duration(seconds: 60); + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: Algorithms.SHA1, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + }); + group('different algorithms 6 digits', () { + const digits = 6; + const period = Duration(seconds: 30); + test('algorithm SHA1', () { + const algorithm = Algorithms.SHA1; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + test('algorithm SHA256', () { + const algorithm = Algorithms.SHA256; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + test('algorithm SHA512', () { + const algorithm = Algorithms.SHA512; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + }); + group('different algorithms 8 digits', () { + const digits = 8; + const period = Duration(seconds: 30); + test('algorithm SHA1', () { + const algorithm = Algorithms.SHA1; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + test('algorithm SHA256', () { + const algorithm = Algorithms.SHA256; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + test('algorithm SHA512', () { + const algorithm = Algorithms.SHA512; + final hotpToken = HOTPToken( + id: '', + label: '', + issuer: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + counter: (DateTime.now().millisecondsSinceEpoch / 1000) ~/ period.inSeconds, + ); + final totpToken = TOTPToken( + period: period.inSeconds, + label: '', + issuer: '', + id: '', + algorithm: algorithm, + digits: digits, + secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), + ); + expect(totpToken.otpValue, hotpToken.otpValue); + }); + }); + }); +} diff --git a/test/unit_test/state_notifiers/app_state_notifier_test.dart b/test/unit_test/state_notifiers/app_state_notifier_test.dart new file mode 100644 index 000000000..111079ae3 --- /dev/null +++ b/test/unit_test/state_notifiers/app_state_notifier_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/model/states/app_state.dart'; +import 'package:privacyidea_authenticator/state_notifiers/app_state_notifier.dart'; + +void main() { + _testAppStateNotifier(); +} + +void _testAppStateNotifier() { + group('AppStateNotifier', () { + final container = ProviderContainer(); + test('setAppState', () { + final testProvider = StateNotifierProvider((ref) => AppStateNotifier()); + final notifier = container.read(testProvider.notifier); + notifier.setAppState(AppState.pause); + expect(notifier.state, AppState.pause); + notifier.setAppState(AppState.resume); + expect(notifier.state, AppState.resume); + notifier.setAppState(AppState.running); + expect(notifier.state, AppState.running); + }); + }); +} diff --git a/test/unit_test/state_notifiers/push_request_notifier_test.dart b/test/unit_test/state_notifiers/push_request_notifier_test.dart new file mode 100644 index 000000000..e6adbed70 --- /dev/null +++ b/test/unit_test/state_notifiers/push_request_notifier_test.dart @@ -0,0 +1,140 @@ +import 'dart:developer'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacyidea_authenticator/model/push_request.dart'; +import 'package:privacyidea_authenticator/model/push_request_queue.dart'; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; +import 'package:privacyidea_authenticator/state_notifiers/push_request_notifier.dart'; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart'; +import 'package:privacyidea_authenticator/utils/network_utils.dart'; +import 'package:privacyidea_authenticator/utils/push_provider.dart'; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart'; +import 'package:mockito/annotations.dart'; + +import 'push_request_notifier_test.mocks.dart'; + +class _MockPushProvider extends Mock implements PushProvider { + @override + PushRequestNotifier? pushSubscriber; + @override + Future initialize({required PushRequestNotifier pushSubscriber, required FirebaseUtils firebaseUtils}) async { + this.pushSubscriber = pushSubscriber; + } + + void simulatePush(PushRequest pushRequest) { + log(pushSubscriber.toString()); + pushSubscriber?.newRequest(pushRequest); + } +} + +@GenerateMocks([RsaUtils, PrivacyIdeaIOClient, FirebaseUtils]) +void main() { + _testPushRequestNotifier(); +} + +void _testPushRequestNotifier() { + group('PushRequestNotifier', () { + test('newRequest', () async { + final container = ProviderContainer(); + final mockPushProvider = _MockPushProvider(); + final mockFirebaseUtils = MockFirebaseUtils(); + final notifier = PushRequestNotifier( + pushProvider: mockPushProvider, + firebaseUtils: mockFirebaseUtils, + ioClient: MockPrivacyIdeaIOClient(), + rsaUtils: MockRsaUtils(), + ); + final testProvider = StateNotifierProvider((ref) => notifier); + await mockPushProvider.initialize(pushSubscriber: notifier, firebaseUtils: mockFirebaseUtils); + final pr = PushRequest( + title: 'title', + question: 'question', + uri: Uri.parse('https://example.com/api/fetch?limit=10,20,30&max=100'), + nonce: 'nonce', + sslVerify: false, + id: 1, + expirationDate: DateTime.now().add(const Duration(minutes: 10)), + ); + log('1'); + mockPushProvider.simulatePush(pr); + expect(container.read(testProvider), pr); + }); + test('accept', () async { + final container = ProviderContainer(); + final mockPushProvider = _MockPushProvider(); + final mockIoClient = MockPrivacyIdeaIOClient(); + final mockRsaUtils = MockRsaUtils(); + final mockFirebaseUtils = MockFirebaseUtils(); + final pr = PushRequest( + title: 'title', + serial: 'serial', + question: 'question', + uri: Uri.parse('https://example.com/api/fetch?limit=10,20,30&max=100'), + nonce: 'nonce', + sslVerify: false, + id: 1, + expirationDate: DateTime.now().add(const Duration(minutes: 10)), + ); + final pushToken = PushToken(serial: 'serial', label: 'label', issuer: 'issuer', id: 'id', pushRequests: PushRequestQueue()..add(pr)); + when(mockRsaUtils.trySignWithToken(pushToken, any)).thenAnswer((_) async => 'signature'); + when(mockIoClient.doPost( + url: Uri.parse('https://example.com/api/fetch?limit=10,20,30&max=100'), + body: {'nonce': 'nonce', 'serial': 'serial', 'signature': 'signature'}, + sslVerify: false)) + .thenAnswer((_) async => Response('', 200)); + final testProvider = StateNotifierProvider((ref) { + final notifier = PushRequestNotifier( + pushProvider: mockPushProvider, + ioClient: mockIoClient, + rsaUtils: mockRsaUtils, + firebaseUtils: mockFirebaseUtils, + ); + mockPushProvider.initialize(pushSubscriber: notifier, firebaseUtils: mockFirebaseUtils); + return notifier; + }); + final notifier = container.read(testProvider.notifier); + await notifier.acceptPop(pushToken); + expect(container.read(testProvider)!.accepted, isTrue); + }); + test('decline', () async { + final container = ProviderContainer(); + final mockPushProvider = _MockPushProvider(); + final mockIoClient = MockPrivacyIdeaIOClient(); + final mockRsaUtils = MockRsaUtils(); + final mockFirebaseUtils = MockFirebaseUtils(); + final pr = PushRequest( + title: 'title', + serial: 'serial', + question: 'question', + uri: Uri.parse('https://example.com/api/fetch?limit=10,20,30&max=100'), + nonce: 'nonce', + sslVerify: false, + id: 1, + expirationDate: DateTime.now().add(const Duration(minutes: 10)), + ); + final pushToken = PushToken(serial: 'serial', label: 'label', issuer: 'issuer', id: 'id', pushRequests: PushRequestQueue()..add(pr)); + when(mockRsaUtils.trySignWithToken(pushToken, any)).thenAnswer((_) async => 'signature'); + when(mockIoClient.doPost( + url: Uri.parse('https://example.com/api/fetch?limit=10,20,30&max=100'), + body: {'nonce': 'nonce', 'serial': 'serial', 'signature': 'signature', 'decline': '1'}, + sslVerify: false)) + .thenAnswer((_) async => Response('', 200)); + final testProvider = StateNotifierProvider((ref) { + final notifier = PushRequestNotifier( + pushProvider: mockPushProvider, + ioClient: mockIoClient, + rsaUtils: mockRsaUtils, + firebaseUtils: mockFirebaseUtils, + ); + mockPushProvider.initialize(pushSubscriber: notifier, firebaseUtils: mockFirebaseUtils); + return notifier; + }); + final notifier = container.read(testProvider.notifier); + await notifier.declinePop(pushToken); + expect(container.read(testProvider)!.accepted, isFalse); + }); + }); +} diff --git a/test/unit_test/state_notifiers/push_request_notifier_test.mocks.dart b/test/unit_test/state_notifiers/push_request_notifier_test.mocks.dart new file mode 100644 index 000000000..765194117 --- /dev/null +++ b/test/unit_test/state_notifiers/push_request_notifier_test.mocks.dart @@ -0,0 +1,368 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in privacyidea_authenticator/test/unit_test/state_notifiers/push_request_notifier_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i6; +import 'dart:typed_data' as _i5; + +import 'package:firebase_messaging/firebase_messaging.dart' as _i10; +import 'package:http/http.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:pointycastle/export.dart' as _i2; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart' as _i7; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart' as _i9; +import 'package:privacyidea_authenticator/utils/network_utils.dart' as _i8; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeRSAPublicKey_0 extends _i1.SmartFake implements _i2.RSAPublicKey { + _FakeRSAPublicKey_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRSAPrivateKey_1 extends _i1.SmartFake implements _i2.RSAPrivateKey { + _FakeRSAPrivateKey_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAsymmetricKeyPair_2 extends _i1.SmartFake + implements _i2.AsymmetricKeyPair { + _FakeAsymmetricKeyPair_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeResponse_3 extends _i1.SmartFake implements _i3.Response { + _FakeResponse_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [RsaUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRsaUtils extends _i1.Mock implements _i4.RsaUtils { + MockRsaUtils() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.RSAPublicKey deserializeRSAPublicKeyPKCS1(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + returnValue: _FakeRSAPublicKey_0( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + ), + ) as _i2.RSAPublicKey); + + @override + String serializeRSAPublicKeyPKCS1(_i2.RSAPublicKey? publicKey) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPublicKeyPKCS1, + [publicKey], + ), + returnValue: '', + ) as String); + + @override + _i2.RSAPublicKey deserializeRSAPublicKeyPKCS8(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + returnValue: _FakeRSAPublicKey_0( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + ), + ) as _i2.RSAPublicKey); + + @override + String serializeRSAPublicKeyPKCS8(_i2.RSAPublicKey? key) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPublicKeyPKCS8, + [key], + ), + returnValue: '', + ) as String); + + @override + String serializeRSAPrivateKeyPKCS1(_i2.RSAPrivateKey? key) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPrivateKeyPKCS1, + [key], + ), + returnValue: '', + ) as String); + + @override + _i2.RSAPrivateKey deserializeRSAPrivateKeyPKCS1(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + returnValue: _FakeRSAPrivateKey_1( + this, + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + ), + ) as _i2.RSAPrivateKey); + + @override + bool verifyRSASignature( + _i2.RSAPublicKey? publicKey, + _i5.Uint8List? signedMessage, + _i5.Uint8List? signature, + ) => + (super.noSuchMethod( + Invocation.method( + #verifyRSASignature, + [ + publicKey, + signedMessage, + signature, + ], + ), + returnValue: false, + ) as bool); + + @override + _i6.Future trySignWithToken( + _i7.PushToken? token, + String? message, + ) => + (super.noSuchMethod( + Invocation.method( + #trySignWithToken, + [ + token, + message, + ], + ), + returnValue: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future<_i2.AsymmetricKeyPair<_i2.RSAPublicKey, _i2.RSAPrivateKey>> + generateRSAKeyPair() => (super.noSuchMethod( + Invocation.method( + #generateRSAKeyPair, + [], + ), + returnValue: _i6.Future< + _i2.AsymmetricKeyPair<_i2.RSAPublicKey, + _i2.RSAPrivateKey>>.value( + _FakeAsymmetricKeyPair_2<_i2.RSAPublicKey, _i2.RSAPrivateKey>( + this, + Invocation.method( + #generateRSAKeyPair, + [], + ), + )), + ) as _i6.Future< + _i2.AsymmetricKeyPair<_i2.RSAPublicKey, _i2.RSAPrivateKey>>); + + @override + String createBase32Signature( + _i2.RSAPrivateKey? privateKey, + _i5.Uint8List? dataToSign, + ) => + (super.noSuchMethod( + Invocation.method( + #createBase32Signature, + [ + privateKey, + dataToSign, + ], + ), + returnValue: '', + ) as String); + + @override + _i5.Uint8List createRSASignature( + _i2.RSAPrivateKey? privateKey, + _i5.Uint8List? dataToSign, + ) => + (super.noSuchMethod( + Invocation.method( + #createRSASignature, + [ + privateKey, + dataToSign, + ], + ), + returnValue: _i5.Uint8List(0), + ) as _i5.Uint8List); +} + +/// A class which mocks [PrivacyIdeaIOClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPrivacyIdeaIOClient extends _i1.Mock + implements _i8.PrivacyIdeaIOClient { + MockPrivacyIdeaIOClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i6.Future triggerNetworkAccessPermission({ + required Uri? url, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #triggerNetworkAccessPermission, + [], + { + #url: url, + #sslVerify: sslVerify, + }, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future<_i3.Response> doPost({ + required Uri? url, + required Map? body, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_3( + this, + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + )), + ) as _i6.Future<_i3.Response>); + + @override + _i6.Future<_i3.Response> doGet({ + required Uri? url, + required Map? parameters, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + returnValue: _i6.Future<_i3.Response>.value(_FakeResponse_3( + this, + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + )), + ) as _i6.Future<_i3.Response>); +} + +/// A class which mocks [FirebaseUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseUtils extends _i1.Mock implements _i9.FirebaseUtils { + MockFirebaseUtils() { + _i1.throwOnMissingStub(this); + } + + @override + _i6.Future initFirebase({ + required _i6.Future Function(_i10.RemoteMessage)? foregroundHandler, + required _i6.Future Function(_i10.RemoteMessage)? backgroundHandler, + required void Function(String?)? updateFirebaseToken, + }) => + (super.noSuchMethod( + Invocation.method( + #initFirebase, + [], + { + #foregroundHandler: foregroundHandler, + #backgroundHandler: backgroundHandler, + #updateFirebaseToken: updateFirebaseToken, + }, + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future getFBToken() => (super.noSuchMethod( + Invocation.method( + #getFBToken, + [], + ), + returnValue: _i6.Future.value(), + ) as _i6.Future); +} diff --git a/test/unit_test/state_notifiers/settings_notifier_test.dart b/test/unit_test/state_notifiers/settings_notifier_test.dart new file mode 100644 index 000000000..f0eb7db10 --- /dev/null +++ b/test/unit_test/state_notifiers/settings_notifier_test.dart @@ -0,0 +1,197 @@ +import 'dart:ui'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/settings_repository.dart'; +import 'package:privacyidea_authenticator/model/states/settings_state.dart'; +import 'package:privacyidea_authenticator/state_notifiers/settings_notifier.dart'; +import 'package:mockito/annotations.dart'; + +import 'settings_notifier_test.mocks.dart'; + +final _state = SettingsState( + isFirstRun: false, + hideOpts: false, + showGuideOnStart: true, + localePreference: const Locale('en'), + useSystemLocale: true, + enablePolling: true, + verboseLogging: false, + crashReportRecipients: {'someone'}, +); + +@GenerateMocks([SettingsRepository]) +void main() { + _testSettingsNotifier(); +} + +void _testSettingsNotifier() { + group('SettingsNotifier', () { + final mockRepo = MockSettingsRepository(); + test('load state from repo on creation', () async { + final container = ProviderContainer(); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: SettingsState( + isFirstRun: true, + hideOpts: true, + showGuideOnStart: false, + localePreference: const Locale('de'), + useSystemLocale: false, + enablePolling: false, + verboseLogging: true, + crashReportRecipients: {'someone'}, + ), + repository: MockSettingsRepository(), + )); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, _state); + verify(mockRepo.loadSettings()).called(1); + }); + }); + + test('addCrashReportRecipient', () { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + crashReportRecipients: {'someone', 'anotherOne'}, + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.addCrashReportRecipient('anotherOne'); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + test('setLocalePreference', () { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + localePreference: const Locale('en'), + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.setLocalePreference(const Locale('en')); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + test('setUseSystemLocale', () { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + useSystemLocale: !_state.useSystemLocale, + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.setUseSystemLocale(!_state.useSystemLocale); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + test('setPolling', () { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + enablePolling: !_state.enablePolling, + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.setPolling(!_state.enablePolling); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + test('setVerboseLogging', () async { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + verboseLogging: !_state.verboseLogging, + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.setVerboseLogging(!_state.verboseLogging); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + test('toggleVerboseLogging', () { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + verboseLogging: !_state.verboseLogging, + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.toggleVerboseLogging(); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + test('setFirstRun', () { + final container = ProviderContainer(); + final copyWithSettings = _state.copyWith( + isFirstRun: !_state.isFirstRun, + ); + when(mockRepo.loadSettings()).thenAnswer((_) async => _state); + when(mockRepo.saveSettings(copyWithSettings)).thenAnswer((_) async => true); + final testProvider = StateNotifierProvider((ref) => SettingsNotifier( + initialState: _state, + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + notifier.setFirstRun(!_state.isFirstRun); + Future.delayed(const Duration(milliseconds: 1000)).then((value) { + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state, copyWithSettings); + verify(mockRepo.saveSettings(copyWithSettings)).called(1); + }); + }); + }); +} diff --git a/test/unit_test/state_notifiers/settings_notifier_test.mocks.dart b/test/unit_test/state_notifiers/settings_notifier_test.mocks.dart new file mode 100644 index 000000000..1e6fd9f14 --- /dev/null +++ b/test/unit_test/state_notifiers/settings_notifier_test.mocks.dart @@ -0,0 +1,68 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in privacyidea_authenticator/test/unit_test/state_notifiers/settings_notifier_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i4; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:privacyidea_authenticator/interfaces/repo/settings_repository.dart' + as _i3; +import 'package:privacyidea_authenticator/model/states/settings_state.dart' + as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeSettingsState_0 extends _i1.SmartFake implements _i2.SettingsState { + _FakeSettingsState_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [SettingsRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSettingsRepository extends _i1.Mock + implements _i3.SettingsRepository { + MockSettingsRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i4.Future saveSettings(_i2.SettingsState? settings) => + (super.noSuchMethod( + Invocation.method( + #saveSettings, + [settings], + ), + returnValue: _i4.Future.value(false), + ) as _i4.Future); + + @override + _i4.Future<_i2.SettingsState> loadSettings() => (super.noSuchMethod( + Invocation.method( + #loadSettings, + [], + ), + returnValue: _i4.Future<_i2.SettingsState>.value(_FakeSettingsState_0( + this, + Invocation.method( + #loadSettings, + [], + ), + )), + ) as _i4.Future<_i2.SettingsState>); +} diff --git a/test/unit_test/state_notifiers/token_folder_notifier_test.dart b/test/unit_test/state_notifiers/token_folder_notifier_test.dart new file mode 100644 index 000000000..163830bbc --- /dev/null +++ b/test/unit_test/state_notifiers/token_folder_notifier_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:privacyidea_authenticator/model/states/token_folder_state.dart'; +import 'package:privacyidea_authenticator/model/token_folder.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/token_folder_repository.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_folder_notifier.dart'; + +import 'token_folder_notifier_test.mocks.dart'; + +@GenerateMocks([TokenFolderRepository]) +void main() { + _testTokenFolderNotifier(); +} + +void _testTokenFolderNotifier() { + group('TokenFolderNotifier', () { + test('addFolder', () async { + final mockRepo = MockTokenFolderRepository(); + final container = ProviderContainer(); + const before = []; + const after = [TokenFolder(label: 'test', folderId: 1, isExpanded: true, isLocked: false, sortIndex: null)]; + when(mockRepo.loadFolders()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceFolders(after)).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider((ref) => TokenFolderNotifier( + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.addFolder('test'); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state.folders, after); + verify(mockRepo.saveOrReplaceFolders(after)).called(1); + }); + + test('removeFolder', () async { + final mockRepo = MockTokenFolderRepository(); + final container = ProviderContainer(); + const before = [TokenFolder(label: 'test', folderId: 1, isExpanded: true, isLocked: false, sortIndex: null)]; + const after = []; + when(mockRepo.loadFolders()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceFolders(after)).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider((ref) => TokenFolderNotifier( + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.removeFolder(const TokenFolder(label: 'test', folderId: 1)); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state.folders, after); + verify(mockRepo.saveOrReplaceFolders(after)).called(1); + }); + test('updateFolder', () async { + final mockRepo = MockTokenFolderRepository(); + final container = ProviderContainer(); + const before = [TokenFolder(label: 'test', folderId: 1, isExpanded: true, isLocked: false, sortIndex: null)]; + const after = [TokenFolder(label: 'testUpdated', folderId: 1, isExpanded: true, isLocked: false, sortIndex: null)]; + when(mockRepo.loadFolders()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceFolders(after)).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider((ref) => TokenFolderNotifier( + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.updateFolder(after.first); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state.folders, after); + verify(mockRepo.saveOrReplaceFolders(after)).called(1); + }); + test('updateFolders', () async { + final mockRepo = MockTokenFolderRepository(); + final container = ProviderContainer(); + const before = [ + TokenFolder(label: 'test1', folderId: 1, isExpanded: true, isLocked: false, sortIndex: null), + TokenFolder(label: 'test2', folderId: 2, isExpanded: true, isLocked: false, sortIndex: null), + ]; + const after = [ + TokenFolder(label: 'test1Updated', folderId: 1, isExpanded: true, isLocked: false, sortIndex: null), + TokenFolder(label: 'test2Updated', folderId: 2, isExpanded: true, isLocked: false, sortIndex: null), + ]; + when(mockRepo.loadFolders()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceFolders(after)).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider((ref) => TokenFolderNotifier( + repository: mockRepo, + )); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.updateFolders(after); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state.folders, after); + verify(mockRepo.saveOrReplaceFolders(after)).called(1); + }); + }); +} diff --git a/test/unit_test/state_notifiers/token_folder_notifier_test.mocks.dart b/test/unit_test/state_notifiers/token_folder_notifier_test.mocks.dart new file mode 100644 index 000000000..b07f2dca9 --- /dev/null +++ b/test/unit_test/state_notifiers/token_folder_notifier_test.mocks.dart @@ -0,0 +1,54 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in privacyidea_authenticator/test/unit_test/state_notifiers/token_folder_notifier_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:privacyidea_authenticator/interfaces/repo/token_folder_repository.dart' + as _i2; +import 'package:privacyidea_authenticator/model/token_folder.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [TokenFolderRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTokenFolderRepository extends _i1.Mock + implements _i2.TokenFolderRepository { + MockTokenFolderRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> saveOrReplaceFolders( + List<_i4.TokenFolder>? folders) => + (super.noSuchMethod( + Invocation.method( + #saveOrReplaceFolders, + [folders], + ), + returnValue: + _i3.Future>.value(<_i4.TokenFolder>[]), + ) as _i3.Future>); + + @override + _i3.Future> loadFolders() => (super.noSuchMethod( + Invocation.method( + #loadFolders, + [], + ), + returnValue: + _i3.Future>.value(<_i4.TokenFolder>[]), + ) as _i3.Future>); +} diff --git a/test/unit_test/state_notifiers/token_notifier_test.dart b/test/unit_test/state_notifiers/token_notifier_test.dart new file mode 100644 index 000000000..036e93cee --- /dev/null +++ b/test/unit_test/state_notifiers/token_notifier_test.dart @@ -0,0 +1,382 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; +import 'package:pi_authenticator_legacy/pi_authenticator_legacy.dart'; +import 'package:pointycastle/export.dart'; +import 'package:privacyidea_authenticator/interfaces/repo/token_repository.dart'; +import 'package:privacyidea_authenticator/model/push_request.dart'; +import 'package:privacyidea_authenticator/model/push_request_queue.dart'; +import 'package:privacyidea_authenticator/model/states/token_state.dart'; +import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart'; +import 'package:privacyidea_authenticator/model/tokens/token.dart'; +import 'package:privacyidea_authenticator/state_notifiers/token_notifier.dart'; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/network_utils.dart'; +import 'package:privacyidea_authenticator/utils/qr_parser.dart'; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart'; +import 'package:privacyidea_authenticator/utils/utils.dart'; + +import 'token_notifier_test.mocks.dart'; + +@GenerateMocks( + [ + TokenRepository, + QrParser, + RsaUtils, + PrivacyIdeaIOClient, + FirebaseUtils, + LegacyUtils, + ], +) +void main() { + _testTokenNotifier(); +} + +void _testTokenNotifier() { + group('TokenNotifier', () { + test('refreshRolledOutPushTokens', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: true, pushRequests: PushRequestQueue()), + ]; + final queue = PushRequestQueue() + ..add(PushRequest( + title: 'title', + question: 'question', + uri: Uri.parse('https://example.com'), + nonce: 'nonce', + sslVerify: false, + id: 1, + expirationDate: DateTime.now().add(const Duration(minutes: 3)))); + final after = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: true, pushRequests: queue), + ]; + final responses = [before, after]; + when(mockRepo.loadTokens()).thenAnswer((_) async => responses.removeAt(0)); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier(repository: mockRepo), + ); + final notifier = container.read(testProvider.notifier); + expect(await notifier.refreshRolledOutPushTokens(), true); + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.loadTokens()).called(2); + }); + test('getTokenFromId', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + ]; + final after = before; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier(repository: mockRepo), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + expect(notifier.getTokenFromId(before.first.id), before.first); + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + }); + test('incrementCounter', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret', counter: 522), + ]; + final after = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret', counter: 523), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([after.first])).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier( + repository: mockRepo, + ), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.incrementCounter(before.first); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens([after.first])).called(1); + }); + test('removeToken', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + HOTPToken(label: 'label2', issuer: 'issuer2', id: 'id2', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret2'), + ]; + final after = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.deleteTokens([before.last])).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier(repository: mockRepo), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.removeToken(before.last); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.deleteTokens([before.last])).called(1); + }); + group('addOrReplaceToken', () { + test('add Token', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + ]; + final after = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + HOTPToken(label: 'label2', issuer: 'issuer2', id: 'id2', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret2'), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([after.last])).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier( + repository: mockRepo, + ), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.addOrReplaceToken(after.last); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens([after.last])).called(1); + }); + test('replace Token', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + HOTPToken(label: 'label2', issuer: 'issuer2', id: 'id2', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret2'), + ]; + final after = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + HOTPToken(label: 'labelUpdated', issuer: 'issuer2Updated', id: 'id2', algorithm: Algorithms.SHA256, digits: 8, secret: 'secret2Updated'), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([after.last])).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier( + repository: mockRepo, + ), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.addOrReplaceToken(after.last); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens([after.last])).called(1); + }); + }); + test('addOrReplaceTokens', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + ]; + final after = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + HOTPToken(label: 'label2', issuer: 'issuer2', id: 'id2', algorithm: Algorithms.SHA256, digits: 6, secret: 'secret2'), + HOTPToken(label: 'label3', issuer: 'issuer3', id: 'id3', algorithm: Algorithms.SHA512, digits: 8, secret: 'secret3'), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([...after])).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier( + repository: mockRepo, + ), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.addOrReplaceTokens([...after]); + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + }); + test('addTokenFromOtpAuth', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final mockQrParser = MockQrParser(); + final before = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + ]; + final after = [ + HOTPToken(label: 'label', issuer: 'issuer', id: 'id', algorithm: Algorithms.SHA1, digits: 6, secret: 'secret'), + HOTPToken(label: 'label2', issuer: 'issuer2', id: 'id2', algorithm: Algorithms.SHA256, digits: 6, secret: 'secret2'), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens(any)).thenAnswer((_) async => []); + when(mockQrParser.parseQRCodeToMap('otpAuthString')).thenReturn({ + URI_LABEL: 'label2', + URI_ISSUER: 'issuer2', + URI_ALGORITHM: 'SHA256', + URI_DIGITS: 6, + URI_SECRET: Uint8List.fromList([73, 65, 63, 72, 65, 74, 32]), + URI_TYPE: enumAsString(TokenTypes.HOTP), + }); + when(mockQrParser.is2StepURI(any)).thenReturn(false); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier(repository: mockRepo, qrParser: mockQrParser), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.addTokenFromOtpAuth(otpAuth: 'otpAuthString'); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + after.last = after.last.copyWith(id: state.tokens.last.id); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens(any)).called(1); + }); + test('addPushRequestToToken', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final mockRsaUtils = MockRsaUtils(); + final before = [ + PushToken( + label: 'label', + issuer: 'issuer', + id: 'id', + serial: 'serial', + isRolledOut: true, + pushRequests: PushRequestQueue(), + url: Uri.parse('https://example.com'), + privateTokenKey: 'privateTokenKey', + ).withPublicServerKey(RSAPublicKey(BigInt.one, BigInt.one)), + ]; + final pr = PushRequest( + title: 'title', + question: 'question', + uri: Uri.parse('https://example.com'), + nonce: 'nonce', + serial: 'serial', + sslVerify: false, + id: 1, + expirationDate: DateTime.now().add(const Duration(minutes: 3)), + ); + final after = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: true, pushRequests: PushRequestQueue()..add(pr)), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([after.first])).thenAnswer((_) async => []); + when(mockRsaUtils.verifyRSASignature(any, any, any)).thenReturn(true); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier(repository: mockRepo, rsaUtils: mockRsaUtils), + ); + final notifier = container.read(testProvider.notifier); + expect(await notifier.addPushRequestToToken(pr), true); + final state = container.read(testProvider); + await notifier.isLoading; + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens([after.first])).called(1); + verify(mockRsaUtils.verifyRSASignature(any, any, any)).called(1); + }); + test('removePushRequest', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final pr = PushRequest( + title: 'title', + question: 'question', + uri: Uri.parse('https://example.com'), + nonce: 'nonce', + serial: 'serial', + sslVerify: false, + id: 1, + expirationDate: DateTime.now().add(const Duration(minutes: 3))); + final before = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: true, pushRequests: PushRequestQueue()..add(pr)), + ]; + final after = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: true, pushRequests: PushRequestQueue()), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([after.first])).thenAnswer((_) async => []); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier(repository: mockRepo), + ); + final notifier = container.read(testProvider.notifier); + await notifier.isLoading; + notifier.removePushRequest(pr); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens([after.first])).called(1); + }); + test('rolloutPushToken', () async { + final container = ProviderContainer(); + final mockRepo = MockTokenRepository(); + final mockIOClient = MockPrivacyIdeaIOClient(); + final mockFirebaseUtils = MockFirebaseUtils(); + final mockRsaUtils = MockRsaUtils(); + final uri = Uri.parse('https://example.com'); + final before = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: false, url: uri), + ]; + final after = [ + PushToken(label: 'label', issuer: 'issuer', id: 'id', serial: 'serial', isRolledOut: true, url: uri), + ]; + when(mockRepo.loadTokens()).thenAnswer((_) async => before); + when(mockRepo.saveOrReplaceTokens([after.first])).thenAnswer((_) async => []); + when(mockRsaUtils.serializeRSAPublicKeyPKCS8(any)).thenAnswer((_) => 'publicKey'); + when(mockRsaUtils.generateRSAKeyPair()).thenAnswer((_) => const RsaUtils() + .generateRSAKeyPair()); // We get here a random result anyway and is it more likely to make errors by mocking it than by using the real method + when(mockFirebaseUtils.getFBToken()).thenAnswer((_) => Future.value('fbToken')); + when(mockRsaUtils.deserializeRSAPublicKeyPKCS1('publicKey')).thenAnswer((_) => RSAPublicKey(BigInt.one, BigInt.one)); + when(mockIOClient.doPost( + url: anyNamed('url'), + body: anyNamed('body'), + sslVerify: anyNamed('sslVerify'), + )).thenAnswer((_) => Future.value(Response('{"detail": {"public_key": "publicKey"}}', 200))); + final testProvider = StateNotifierProvider( + (ref) => TokenNotifier( + repository: mockRepo, + qrParser: MockQrParser(), + rsaUtils: mockRsaUtils, + ioClient: mockIOClient, + firebaseUtils: mockFirebaseUtils, + ), + ); + final notifier = container.read(testProvider.notifier); + expect(await notifier.rolloutPushToken(before.first), true); + await notifier.isLoading; + final state = container.read(testProvider); + expect(state, isNotNull); + expect(state.tokens, after); + verify(mockRepo.saveOrReplaceTokens([after.first])).called(greaterThan(0)); + verify(mockRsaUtils.serializeRSAPublicKeyPKCS8(any)).called(greaterThan(0)); + verify(mockFirebaseUtils.getFBToken()).called(greaterThan(0)); + verify(mockRsaUtils.deserializeRSAPublicKeyPKCS1('publicKey')).called(greaterThan(0)); + verify(mockIOClient.doPost( + url: anyNamed('url'), + body: anyNamed('body'), + sslVerify: anyNamed('sslVerify'), + )).called(greaterThan(0)); + }); + }); +} diff --git a/test/unit_test/state_notifiers/token_notifier_test.mocks.dart b/test/unit_test/state_notifiers/token_notifier_test.mocks.dart new file mode 100644 index 000000000..202ea07ce --- /dev/null +++ b/test/unit_test/state_notifiers/token_notifier_test.mocks.dart @@ -0,0 +1,482 @@ +// Mocks generated by Mockito 5.4.2 from annotations +// in privacyidea_authenticator/test/unit_test/state_notifiers/token_notifier_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; +import 'dart:typed_data' as _i9; + +import 'package:firebase_messaging/firebase_messaging.dart' as _i13; +import 'package:http/http.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:pi_authenticator_legacy/pi_authenticator_legacy.dart' as _i14; +import 'package:pointycastle/export.dart' as _i2; +import 'package:privacyidea_authenticator/interfaces/repo/token_repository.dart' + as _i4; +import 'package:privacyidea_authenticator/model/tokens/push_token.dart' as _i10; +import 'package:privacyidea_authenticator/model/tokens/token.dart' as _i6; +import 'package:privacyidea_authenticator/utils/firebase_utils.dart' as _i12; +import 'package:privacyidea_authenticator/utils/network_utils.dart' as _i11; +import 'package:privacyidea_authenticator/utils/qr_parser.dart' as _i7; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart' as _i8; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +class _FakeRSAPublicKey_0 extends _i1.SmartFake implements _i2.RSAPublicKey { + _FakeRSAPublicKey_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeRSAPrivateKey_1 extends _i1.SmartFake implements _i2.RSAPrivateKey { + _FakeRSAPrivateKey_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeAsymmetricKeyPair_2 extends _i1.SmartFake + implements _i2.AsymmetricKeyPair { + _FakeAsymmetricKeyPair_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +class _FakeResponse_3 extends _i1.SmartFake implements _i3.Response { + _FakeResponse_3( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + +/// A class which mocks [TokenRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTokenRepository extends _i1.Mock implements _i4.TokenRepository { + MockTokenRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future> saveOrReplaceTokens(List<_i6.Token>? tokens) => + (super.noSuchMethod( + Invocation.method( + #saveOrReplaceTokens, + [tokens], + ), + returnValue: _i5.Future>.value(<_i6.Token>[]), + ) as _i5.Future>); + + @override + _i5.Future> loadTokens() => (super.noSuchMethod( + Invocation.method( + #loadTokens, + [], + ), + returnValue: _i5.Future>.value(<_i6.Token>[]), + ) as _i5.Future>); + + @override + _i5.Future> deleteTokens(List<_i6.Token>? tokens) => + (super.noSuchMethod( + Invocation.method( + #deleteTokens, + [tokens], + ), + returnValue: _i5.Future>.value(<_i6.Token>[]), + ) as _i5.Future>); +} + +/// A class which mocks [QrParser]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockQrParser extends _i1.Mock implements _i7.QrParser { + MockQrParser() { + _i1.throwOnMissingStub(this); + } + + @override + Map parseQRCodeToMap(String? uriAsString) => + (super.noSuchMethod( + Invocation.method( + #parseQRCodeToMap, + [uriAsString], + ), + returnValue: {}, + ) as Map); + + @override + bool is2StepURI(Uri? uri) => (super.noSuchMethod( + Invocation.method( + #is2StepURI, + [uri], + ), + returnValue: false, + ) as bool); +} + +/// A class which mocks [RsaUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockRsaUtils extends _i1.Mock implements _i8.RsaUtils { + MockRsaUtils() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.RSAPublicKey deserializeRSAPublicKeyPKCS1(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + returnValue: _FakeRSAPublicKey_0( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS1, + [keyStr], + ), + ), + ) as _i2.RSAPublicKey); + + @override + String serializeRSAPublicKeyPKCS1(_i2.RSAPublicKey? publicKey) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPublicKeyPKCS1, + [publicKey], + ), + returnValue: '', + ) as String); + + @override + _i2.RSAPublicKey deserializeRSAPublicKeyPKCS8(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + returnValue: _FakeRSAPublicKey_0( + this, + Invocation.method( + #deserializeRSAPublicKeyPKCS8, + [keyStr], + ), + ), + ) as _i2.RSAPublicKey); + + @override + String serializeRSAPublicKeyPKCS8(_i2.RSAPublicKey? key) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPublicKeyPKCS8, + [key], + ), + returnValue: '', + ) as String); + + @override + String serializeRSAPrivateKeyPKCS1(_i2.RSAPrivateKey? key) => + (super.noSuchMethod( + Invocation.method( + #serializeRSAPrivateKeyPKCS1, + [key], + ), + returnValue: '', + ) as String); + + @override + _i2.RSAPrivateKey deserializeRSAPrivateKeyPKCS1(String? keyStr) => + (super.noSuchMethod( + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + returnValue: _FakeRSAPrivateKey_1( + this, + Invocation.method( + #deserializeRSAPrivateKeyPKCS1, + [keyStr], + ), + ), + ) as _i2.RSAPrivateKey); + + @override + bool verifyRSASignature( + _i2.RSAPublicKey? publicKey, + _i9.Uint8List? signedMessage, + _i9.Uint8List? signature, + ) => + (super.noSuchMethod( + Invocation.method( + #verifyRSASignature, + [ + publicKey, + signedMessage, + signature, + ], + ), + returnValue: false, + ) as bool); + + @override + _i5.Future trySignWithToken( + _i10.PushToken? token, + String? message, + ) => + (super.noSuchMethod( + Invocation.method( + #trySignWithToken, + [ + token, + message, + ], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i2.AsymmetricKeyPair<_i2.RSAPublicKey, _i2.RSAPrivateKey>> + generateRSAKeyPair() => (super.noSuchMethod( + Invocation.method( + #generateRSAKeyPair, + [], + ), + returnValue: _i5.Future< + _i2.AsymmetricKeyPair<_i2.RSAPublicKey, + _i2.RSAPrivateKey>>.value( + _FakeAsymmetricKeyPair_2<_i2.RSAPublicKey, _i2.RSAPrivateKey>( + this, + Invocation.method( + #generateRSAKeyPair, + [], + ), + )), + ) as _i5.Future< + _i2.AsymmetricKeyPair<_i2.RSAPublicKey, _i2.RSAPrivateKey>>); + + @override + String createBase32Signature( + _i2.RSAPrivateKey? privateKey, + _i9.Uint8List? dataToSign, + ) => + (super.noSuchMethod( + Invocation.method( + #createBase32Signature, + [ + privateKey, + dataToSign, + ], + ), + returnValue: '', + ) as String); + + @override + _i9.Uint8List createRSASignature( + _i2.RSAPrivateKey? privateKey, + _i9.Uint8List? dataToSign, + ) => + (super.noSuchMethod( + Invocation.method( + #createRSASignature, + [ + privateKey, + dataToSign, + ], + ), + returnValue: _i9.Uint8List(0), + ) as _i9.Uint8List); +} + +/// A class which mocks [PrivacyIdeaIOClient]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockPrivacyIdeaIOClient extends _i1.Mock + implements _i11.PrivacyIdeaIOClient { + MockPrivacyIdeaIOClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future triggerNetworkAccessPermission({ + required Uri? url, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #triggerNetworkAccessPermission, + [], + { + #url: url, + #sslVerify: sslVerify, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future<_i3.Response> doPost({ + required Uri? url, + required Map? body, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + returnValue: _i5.Future<_i3.Response>.value(_FakeResponse_3( + this, + Invocation.method( + #doPost, + [], + { + #url: url, + #body: body, + #sslVerify: sslVerify, + }, + ), + )), + ) as _i5.Future<_i3.Response>); + + @override + _i5.Future<_i3.Response> doGet({ + required Uri? url, + required Map? parameters, + bool? sslVerify = true, + }) => + (super.noSuchMethod( + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + returnValue: _i5.Future<_i3.Response>.value(_FakeResponse_3( + this, + Invocation.method( + #doGet, + [], + { + #url: url, + #parameters: parameters, + #sslVerify: sslVerify, + }, + ), + )), + ) as _i5.Future<_i3.Response>); +} + +/// A class which mocks [FirebaseUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFirebaseUtils extends _i1.Mock implements _i12.FirebaseUtils { + MockFirebaseUtils() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future initFirebase({ + required _i5.Future Function(_i13.RemoteMessage)? foregroundHandler, + required _i5.Future Function(_i13.RemoteMessage)? backgroundHandler, + required void Function(String?)? updateFirebaseToken, + }) => + (super.noSuchMethod( + Invocation.method( + #initFirebase, + [], + { + #foregroundHandler: foregroundHandler, + #backgroundHandler: backgroundHandler, + #updateFirebaseToken: updateFirebaseToken, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future getFBToken() => (super.noSuchMethod( + Invocation.method( + #getFBToken, + [], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); +} + +/// A class which mocks [LegacyUtils]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLegacyUtils extends _i1.Mock implements _i14.LegacyUtils { + MockLegacyUtils() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future sign( + String? serial, + String? message, + ) => + (super.noSuchMethod( + Invocation.method( + #sign, + [ + serial, + message, + ], + ), + returnValue: _i5.Future.value(''), + ) as _i5.Future); + + @override + _i5.Future verify( + String? serial, + String? signedData, + String? signature, + ) => + (super.noSuchMethod( + Invocation.method( + #verify, + [ + serial, + signedData, + signature, + ], + ), + returnValue: _i5.Future.value(false), + ) as _i5.Future); +} diff --git a/test/unit_test/utils/crypto_utils_test.dart b/test/unit_test/utils/crypto_utils_test.dart new file mode 100644 index 000000000..c2bf49811 --- /dev/null +++ b/test/unit_test/utils/crypto_utils_test.dart @@ -0,0 +1,458 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; + +void main() { + _testGeneratePhoneChecksum(); + _testPbkdf2(); + _testDecodeSecretToUint8(); + _testEncodeSecretAs(); + _testIsValidEncoding(); +} + +/// Just a helper method to make tests shorter +Future generateWrapper(List l) async { + return generatePhoneChecksum(phonePart: Uint8List.fromList(l)); +} + +void _testGeneratePhoneChecksum() { + group('generatePhoneChecksum', () { + test('1. SHA-1', () async => expect(await generateWrapper([0, 1, 2, 3, 4, 5, 6]), 'NXEG6EIAAEBAGBAFAY')); + test('2. SHA-1', () async => expect(await generateWrapper([9, 8, 7, 6, 5, 4, 3, 2, 1]), 'THKHQSYJBADQMBIEAMBAC')); + test('3. SHA-1', () async => expect(await generateWrapper([3, 5, 7, 2, 3, 4, 9, 1, 0, 4, 7, 3, 5, 6]), 'TGEEJ7QDAUDQEAYEBEAQABAHAMCQM')); + test('4. SHA-1', () async => expect(await generateWrapper([9, 5, 8, 1, 7, 3]), '2DO4TDAJAUEACBYD')); + test('5. SHA-1', () async => expect(await generateWrapper([1, 0, 2, 9, 3, 8, 4, 7, 5, 6]), 'ZOOALWIBAABASAYIAQDQKBQ')); + }); +} + +void _testPbkdf2() { + group('pbkdf2', () { + Uint8List password = Uint8List.fromList([4, 142, 237, 243, 55, 58, 148, 100, 127, 56, 11, 99, 75, 217, 3, 59, 121, 167, 42, 164]); + Uint8List salt = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]); + int iterations = 10000; + int keyLen = 20; + + group('Different passwords', () { + test( + 'Pwd 1', + () async => expect( + await pbkdf2( + password: Uint8List.fromList([204, 142, 237, 243, 154, 5, 48, 206, 127, 56, 11, 156, 75, 217, 116, 59, 121, 67, 152, 46]), + keyLength: keyLen, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([105, 176, 234, 116, 177, 125, 213, 148, 111, 87, 172, 184, 141, 16, 185, 208, 250, 127, 212, 64])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Pwd 2', + () async => expect( + await pbkdf2( + password: Uint8List.fromList([66, 142, 237, 243, 12, 5, 48, 206, 127, 56, 11, 99, 75, 217, 116, 59, 121, 167, 152, 4]), + keyLength: keyLen, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([11, 157, 107, 247, 204, 194, 23, 69, 211, 238, 200, 86, 38, 234, 99, 227, 247, 44, 220, 135])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Pwd 3', + () async => expect( + await pbkdf2( + password: Uint8List.fromList([222, 142, 237, 243, 55, 5, 48, 0, 127, 56, 11, 99, 75, 217, 3, 59, 121, 167, 152, 164]), + keyLength: keyLen, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([57, 88, 51, 7, 80, 51, 239, 58, 125, 6, 80, 79, 80, 62, 16, 0, 255, 245, 137, 168])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Pwd 4', + () async => expect( + await pbkdf2( + password: Uint8List.fromList([4, 142, 237, 243, 55, 58, 148, 100, 127, 56, 11, 99, 75, 217, 3, 59, 121, 167, 42, 164]), + keyLength: keyLen, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221])), + timeout: const Timeout(Duration(seconds: 60)), + ); + }); + + group('Different salts', () { + test( + 'Salt 1', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: iterations, + salt: Uint8List.fromList([0, 0, 0, 0, 0, 0, 0, 0]), + ), + Uint8List.fromList([0, 149, 53, 169, 140, 36, 152, 54, 213, 123, 214, 14, 11, 199, 89, 78, 180, 108, 104, 177])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Salt 2', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: iterations, + salt: Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]), + ), + Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Salt 3', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: iterations, + salt: Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5]), + ), + Uint8List.fromList([29, 98, 40, 192, 122, 52, 24, 18, 189, 124, 119, 99, 251, 64, 81, 75, 149, 176, 77, 210])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Salt 4', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: iterations, + salt: Uint8List.fromList([42, 42, 42, 5, 6, 7, 8, 42]), + ), + Uint8List.fromList([196, 70, 123, 140, 14, 167, 102, 50, 223, 223, 120, 158, 35, 10, 215, 202, 117, 26, 85, 46])), + timeout: const Timeout(Duration(seconds: 60)), + ); + }); + + group( + 'Different iterations', + () { + test( + '100', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: 100, + salt: salt, + ), + Uint8List.fromList([126, 248, 52, 21, 94, 28, 200, 201, 165, 237, 0, 31, 10, 157, 59, 76, 63, 189, 247, 132])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + '1000', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: 1000, + salt: salt, + ), + Uint8List.fromList([70, 150, 241, 120, 152, 55, 135, 238, 232, 88, 94, 42, 245, 251, 156, 76, 165, 128, 102, 119])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + '10 000', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: 10000, + salt: salt, + ), + Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + '100 000', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: 100000, + salt: salt, + ), + Uint8List.fromList([60, 246, 237, 212, 183, 224, 78, 28, 204, 190, 27, 137, 164, 163, 80, 89, 21, 81, 244, 109])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + '1 000 000', + () async => expect( + await pbkdf2( + password: password, + keyLength: keyLen, + iterations: 1000000, + salt: salt, + ), + Uint8List.fromList([25, 39, 153, 115, 182, 177, 160, 241, 96, 198, 31, 79, 145, 109, 102, 47, 205, 167, 246, 253])), + timeout: const Timeout(Duration(seconds: 60)), + ); + }, + ); + + group('Different output lengths', () { + test( + 'Key lenght 1', + () async => expect( + await pbkdf2( + password: password, + keyLength: 1, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([135]))); + + test( + 'Key lenght 5', + () async => expect( + await pbkdf2( + password: password, + keyLength: 5, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([135, 33, 148, 191, 86]))); + test( + 'Key lenght 12', + () async => expect( + await pbkdf2( + password: password, + keyLength: 12, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246]))); + + test( + 'Key lenght 20', + () async => expect( + await pbkdf2( + password: password, + keyLength: 20, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221]))); + + test( + 'Key lenght 33', + () async => expect( + await pbkdf2( + password: password, + keyLength: 33, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([ + 135, + 33, + 148, + 191, + 86, + 136, + 13, + 50, + 14, + 0, + 188, + 246, + 48, + 26, + 209, + 229, + 68, + 239, + 111, + 221, + 6, + 22, + 78, + 185, + 134, + 87, + 110, + 131, + 183, + 7, + 5, + 208, + 219 + ])), + timeout: const Timeout(Duration(seconds: 60)), + ); + + test( + 'Key lenght 55', + () async => expect( + await pbkdf2( + password: password, + keyLength: 55, + iterations: iterations, + salt: salt, + ), + Uint8List.fromList([ + 135, + 33, + 148, + 191, + 86, + 136, + 13, + 50, + 14, + 0, + 188, + 246, + 48, + 26, + 209, + 229, + 68, + 239, + 111, + 221, + 6, + 22, + 78, + 185, + 134, + 87, + 110, + 131, + 183, + 7, + 5, + 208, + 219, + 82, + 16, + 35, + 40, + 99, + 223, + 134, + 45, + 102, + 101, + 59, + 19, + 20, + 47, + 119, + 212, + 164, + 58, + 255, + 137, + 22, + 83 + ])), + timeout: const Timeout(Duration(seconds: 60)), + ); + }); + }); +} + +void _testDecodeSecretToUint8() { + group('decodeSecretToUint8', () { + test('Test non hex secret', () { + expect(() => decodeSecretToUint8('oo', Encodings.hex), throwsFormatException); + expect(() => decodeSecretToUint8('1Aö', Encodings.hex), throwsFormatException); + }); + + test('Test hex secret', () { + expect(decodeSecretToUint8('ABCD', Encodings.hex), Uint8List.fromList([171, 205])); + expect(decodeSecretToUint8('0FF8', Encodings.hex), Uint8List.fromList([15, 248])); + }); + + test('Test non base32 secret', () { + expect(() => decodeSecretToUint8('p', Encodings.base32), throwsFormatException); + expect(() => decodeSecretToUint8('AAAAAAöA', Encodings.base32), throwsFormatException); + }); + + test('Test base32 secret', () { + expect(decodeSecretToUint8('OBZGS5TBMN4Q====', Encodings.base32), Uint8List.fromList([112, 114, 105, 118, 97, 99, 121])); + expect(decodeSecretToUint8('JFCEKQI=', Encodings.base32), Uint8List.fromList([73, 68, 69, 65])); + }); + + test('Test utf-8 secret', () { + expect(decodeSecretToUint8('ABCD', Encodings.none), Uint8List.fromList([65, 66, 67, 68])); + expect(decodeSecretToUint8('DEG3', Encodings.none), Uint8List.fromList([68, 69, 71, 51])); + }); + }); +} + +void _testEncodeSecretAs() { + group('encodeSecretAs', () { + test('Test hex secret', () { + expect(encodeSecretAs(Uint8List.fromList([171, 205]), Encodings.hex), 'abcd'); + expect(encodeSecretAs(Uint8List.fromList([15, 248]), Encodings.hex), '0ff8'); + }); + + test('Test base32 secret', () { + expect(encodeSecretAs(Uint8List.fromList([112, 114, 105, 118, 97, 99, 121]), Encodings.base32), 'OBZGS5TBMN4Q===='); + expect(encodeSecretAs(Uint8List.fromList([73, 68, 69, 65]), Encodings.base32), 'JFCEKQI='); + }); + + test('Test utf-8 secret', () { + expect(encodeSecretAs(Uint8List.fromList([65, 66, 67, 68]), Encodings.none), 'ABCD'); + expect(encodeSecretAs(Uint8List.fromList([68, 69, 71, 51]), Encodings.none), 'DEG3'); + }); + }); +} + +void _testIsValidEncoding() { + group('isValidEncoding', () { + group('valid encodings', () { + test('valid hex', () => expect(isValidEncoding('abcd', Encodings.hex), true)); + test('valid base32', () => expect(isValidEncoding('OBZGS5TBMN4Q====', Encodings.base32), true)); + }); + + group('invalid encodings', () { + test('invalid hex', () => expect(isValidEncoding('RXYZ', Encodings.hex), false)); + test('invalid base32', () => expect(isValidEncoding('????', Encodings.base32), false)); + }); + }); +} diff --git a/test/unit_test/utils/custom_int_buffer_test.dart b/test/unit_test/utils/custom_int_buffer_test.dart new file mode 100644 index 000000000..8c7a204e1 --- /dev/null +++ b/test/unit_test/utils/custom_int_buffer_test.dart @@ -0,0 +1,63 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/utils/custom_int_buffer.dart'; + +void main() { + verifyCustomStringBufferWorks(); +} + +void verifyCustomStringBufferWorks() { + group('test custom string buffer', () { + test('put elements in', () { + CustomIntBuffer buffer = CustomIntBuffer(); + buffer.list = []; + + expect(buffer.maxSize, 30); + expect(buffer.length, 0); + + buffer.put(1); + buffer.put(2); + buffer.put(3); + + expect(buffer.length, 3); + + expect(buffer.contains(1), true); + expect(buffer.contains(2), true); + expect(buffer.contains(3), true); + expect(buffer.contains(4), false); + + for (int i = 3; i < buffer.maxSize; i++) { + buffer.put(-1); + } + + buffer.put(4); + + expect(buffer.length, 30); + expect(buffer.maxSize, 30); + + expect(buffer.contains(1), false); + expect(buffer.contains(2), true); + expect(buffer.contains(3), true); + expect(buffer.contains(4), true); + }); + }); +} diff --git a/test/unit_test/utils/parser/qr_parser_test.dart b/test/unit_test/utils/parser/qr_parser_test.dart new file mode 100644 index 000000000..e61c71b7a --- /dev/null +++ b/test/unit_test/utils/parser/qr_parser_test.dart @@ -0,0 +1,227 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/qr_parser.dart'; + +void main() { + _testParsingLabelAndIssuer(); + _testParseOtpAuth(); +} + +void _testParsingLabelAndIssuer() { + group('Parsing Label and Issuer', () { + QrParser qrParser = const QrParser(); + test('Test parse issuer from param', () { + String uriWithoutIssuerAndLabel = 'otpauth://totp/?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&algorithm=SHA512&digits=8&period=60'; + Map map = qrParser.parseQRCodeToMap(uriWithoutIssuerAndLabel); + expect(map[URI_LABEL], ''); + expect(map[URI_ISSUER], ''); + }); + + test('Test parse issuer from param', () { + String uriWithIssuerParam = 'otpauth://totp/alice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'; + Map map = qrParser.parseQRCodeToMap(uriWithIssuerParam); + expect(map[URI_LABEL], 'alice@google.com'); + expect(map[URI_ISSUER], 'ACME Co'); + }); + + test('Test parse issuer from label', () { + String uriWithIssuer = 'otpauth://totp/Example:alice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&algorithm=SHA512&digits=8&period=60'; + Map map = qrParser.parseQRCodeToMap(uriWithIssuer); + expect(map[URI_LABEL], 'alice@google.com'); + expect(map[URI_ISSUER], 'Example'); + }); + + test('Test parse issuer from label with uri encoding', () { + String uriWithIssuerAndUriEncoding = 'otpauth://totp/Example%3Aalice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&algorithm=SHA512&digits=8&period=60'; + Map map = qrParser.parseQRCodeToMap(uriWithIssuerAndUriEncoding); + expect(map[URI_LABEL], 'alice@google.com'); + expect(map[URI_ISSUER], 'Example'); + }); + + test('Test parse issuer from param and label', () { + String uriWithIssuerParamAndIssuer = 'otpauth://totp/Example:alice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'; + Map map = qrParser.parseQRCodeToMap(uriWithIssuerParamAndIssuer); + expect(map[URI_LABEL], 'alice@google.com'); + expect(map[URI_ISSUER], 'Example'); + }); + }); +} + +void _testParseOtpAuth() { + group('Parse TOTP uri', () { + const qrParser = QrParser(); + test('Test with wrong uri schema', () { + expect( + () => qrParser.parseQRCodeToMap('http://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=6&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test with unknown type', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://asdf/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=6&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test with missing type', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth:///ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=6&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test missing algorithm', () { + Map map = qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&digits=6&period=30'); + expect(map[URI_ALGORITHM], 'SHA1'); // This is the default value + }); + + test('Test unknown algorithm', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=BubbleSort&digits=6&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test missing digits', () { + Map map = qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&period=30'); + expect(map[URI_DIGITS], 6); // This is the default value + }); + + // At least the library used to calculate otp values does not support other number of digits. + test('Test invalid number of digits', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=66&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test invalid characters for digits', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=aA&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test missing secret', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30'), + throwsA(const TypeMatcher())); + }); + + test('Test invalid secret', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=ÖÖ&issuer=ACME%20Co&algorithm=SHA1&digits=6' + '&period=30'), + throwsA(const TypeMatcher())); + }); + + // TOTP specific + test('Test missing period', () { + Map map = qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=6'); + expect(map[URI_PERIOD], 30); + }); + + test('Test invalid characters for period', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=6&period=aa'), + throwsA(const TypeMatcher())); + }); + + test('Test longer values for period', () { + Map map = qrParser.parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' + 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' + '&algorithm=SHA1&digits=6&period=124432'); + + expect(map[URI_PERIOD], 124432); + }); + + test('Test valid totp uri', () { + Map map = qrParser.parseQRCodeToMap('otpauth://totp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'); + expect(map[URI_LABEL], 'Kitchen'); + expect(map[URI_ALGORITHM], 'SHA512'); + expect(map[URI_DIGITS], 8); + expect(map[URI_SECRET], decodeSecretToUint8('HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ', Encodings.base32)); + expect(map[URI_PERIOD], 60); + }); + }); + group('Parse HOTP uri', () { + const qrParser = QrParser(); + // HOTP specific + test('Test with missing counter', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://hotp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&issuer=ACME%20Co&algorithm=SHA256&digits=8'), + throwsA(const TypeMatcher())); + }); + + test('Test with invalid counter', () { + expect( + () => qrParser.parseQRCodeToMap('otpauth://hotp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&issuer=ACME%20Co&algorithm=SHA256&digits=8&counter=aa'), + throwsA(const TypeMatcher())); + }); + + test('Test valid hotp uri', () { + Map map = qrParser.parseQRCodeToMap('otpauth://hotp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' + '&issuer=ACME%20Co&algorithm=SHA256&digits=8&counter=5'); + expect(map[URI_LABEL], 'Kitchen'); + expect(map[URI_ALGORITHM], 'SHA256'); + expect(map[URI_DIGITS], 8); + expect(map[URI_SECRET], decodeSecretToUint8('HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ', Encodings.base32)); + expect(map[URI_COUNTER], 5); + }); + }); + group('2 Step Rollout', () { + const qrParser = QrParser(); + test('is2StepURI', () { + expect( + qrParser.is2StepURI(Uri.parse( + 'otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8&2step_output=20')), + true, + ); + expect( + qrParser.is2StepURI(Uri.parse( + 'otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8&2step_difficulty=10000')), + true, + ); + expect( + qrParser.is2StepURI(Uri.parse( + 'otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_output=20&2step_difficulty=10000')), + true); + expect( + qrParser + .is2StepURI(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8')), + true, + ); + + expect( + qrParser.is2StepURI(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA')), + false, + ); + }); + }); +} diff --git a/test/model/token_test.dart b/test/unit_test/utils/push_request_queue_test.dart similarity index 84% rename from test/model/token_test.dart rename to test/unit_test/utils/push_request_queue_test.dart index 2bae2b18e..23a2dd4d2 100644 --- a/test/model/token_test.dart +++ b/test/unit_test/utils/push_request_queue_test.dart @@ -21,15 +21,14 @@ import 'dart:collection'; import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; import 'package:privacyidea_authenticator/model/push_request.dart'; import 'package:privacyidea_authenticator/model/push_request_queue.dart'; -import 'package:privacyidea_authenticator/utils/custom_int_buffer.dart'; -import 'package:test/test.dart'; + import 'package:uuid/uuid.dart'; void main() { verifyCustomListBehavesLikeQueue(); - verifyCustomStringBufferWorks(); } void verifyCustomListBehavesLikeQueue() { @@ -206,40 +205,3 @@ void verifyCustomListBehavesLikeQueue() { }); }); } - -void verifyCustomStringBufferWorks() { - group('test custom string buffer', () { - test('put elements in', () { - CustomIntBuffer buffer = CustomIntBuffer(); - buffer.list = []; - - expect(buffer.maxSize, 30); - expect(buffer.length, 0); - - buffer.put(1); - buffer.put(2); - buffer.put(3); - - expect(buffer.length, 3); - - expect(buffer.contains(1), true); - expect(buffer.contains(2), true); - expect(buffer.contains(3), true); - expect(buffer.contains(4), false); - - for (int i = 3; i < buffer.maxSize; i++) { - buffer.put(-1); - } - - buffer.put(4); - - expect(buffer.length, 30); - expect(buffer.maxSize, 30); - - expect(buffer.contains(1), false); - expect(buffer.contains(2), true); - expect(buffer.contains(3), true); - expect(buffer.contains(4), true); - }); - }); -} diff --git a/test/unit_test/utils/rsa_utils_test.dart b/test/unit_test/utils/rsa_utils_test.dart new file mode 100644 index 000000000..4fc8dbbd3 --- /dev/null +++ b/test/unit_test/utils/rsa_utils_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pointycastle/export.dart'; +import 'package:privacyidea_authenticator/utils/rsa_utils.dart'; + +void main() { + _testSerializingRSAKeys(); +} + +void _testSerializingRSAKeys() { + group('PKCS#1 format', () { + const rsaUtils = RsaUtils(); + test('Converting key', () async { + RSAPublicKey publicKey = RSAPublicKey(BigInt.from(431254), BigInt.from(32545)); + + String base64String = rsaUtils.serializeRSAPublicKeyPKCS1(publicKey); + RSAPublicKey convertedKey = rsaUtils.deserializeRSAPublicKeyPKCS1(base64String); + + expect(publicKey.modulus, convertedKey.modulus); + expect(publicKey.exponent, convertedKey.exponent); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('Converting generated key', () async { + var asymmetricKeyPair = await rsaUtils.generateRSAKeyPair(); + RSAPublicKey publicKey = asymmetricKeyPair.publicKey; + + String base64String = rsaUtils.serializeRSAPublicKeyPKCS1(publicKey); + RSAPublicKey convertedKey = rsaUtils.deserializeRSAPublicKeyPKCS1(base64String); + + expect(publicKey.modulus, convertedKey.modulus); + expect(publicKey.exponent, convertedKey.exponent); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('Parsing existing key', () async { + String serializedPublicKey = 'MIICCgKCAgEAtOE6hDrwB+9Quk5Ibp9DduUMAmQ' + 'i3KSn4pSZPrj4vhx9COenh+K6NtWFDwSPZcEOMk/s7GXsgAzdQvUVp4KpmBSAL3C' + 'XgwZrhG4DZWRvXhB4P0Toxz1McVnPvabriWqU1L3Jorca1bnlvaaYh9rywbBrxes' + 'IA4VUmfFoWHpn+HMdYp4g2UG1UeBIqBsgI4syPiwlEDW6sWTeSDcvQWTYGBsHMXf' + 'zqNGT6ONo5mTSGqI7F75+KtJdtWfNxOKC9pKXXDG8UlgkkhWu0N6sCu/1PEsDxrc' + 'pW7sKKrrB37J8jbEIOHzg67LgCWqFQMoBmIVRHlzQb5HKIswP10AmjJ7Mks0H1db' + 'jK0/ONnU4A9QzjM0ZQt3mvCe8gE0FwQa7CYv8o1OKItQaxPhqBvcLJqjjXc8iFwJ' + 'Qx5XsFU9jMJskQo+2pBBdW7oGRNqdyX0Zx36OQ48OaqbTciNT7oVQrIPd0oIiHjD' + 'LnwBvwn3y5HmvmczdFAs2gQSryJ2/tS/zxrT/OjcGK4JQGDzbjog4fz7kox0PnGg' + 'ssLfoonhflfpM5Om3vGePeqNnISTbA/yCH7X07dZf2BT5/41/OKzNjGzShFNwifb' + 'WBf1mlwUNh1Vuu+ZGdTQKisxI4G8k2dZrlTWkQqOmLebCE3L38jnh0Oek+Jl9fNm' + 'TcMl8sPWxB8lgGpUCAwEAAQ=='; + + expect(rsaUtils.serializeRSAPublicKeyPKCS1(rsaUtils.deserializeRSAPublicKeyPKCS1(serializedPublicKey)), serializedPublicKey); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('PKCS#8 format', () { + const rsaUtils = RsaUtils(); + test('Converting key', () async { + RSAPublicKey publicKey = RSAPublicKey(BigInt.from(431254), BigInt.from(32545)); + + String base64String = rsaUtils.serializeRSAPublicKeyPKCS8(publicKey); + RSAPublicKey convertedKey = rsaUtils.deserializeRSAPublicKeyPKCS8(base64String); + + expect(publicKey.modulus, convertedKey.modulus); + expect(publicKey.exponent, convertedKey.exponent); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('Converting generated key', () async { + var asymmetricKeyPair = await rsaUtils.generateRSAKeyPair(); + RSAPublicKey publicKey = asymmetricKeyPair.publicKey; + + String base64String = rsaUtils.serializeRSAPublicKeyPKCS8(publicKey); + RSAPublicKey convertedKey = rsaUtils.deserializeRSAPublicKeyPKCS8(base64String); + + expect(publicKey.modulus, convertedKey.modulus); + expect(publicKey.exponent, convertedKey.exponent); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('Parse existing key', () async { + String serializedPublicKey = 'MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCA' + 'gEAwdxugfnlsrd3rwZsEvI8GzEF4BtGEK3+vXRWVv43Z0Itn9NAtN5TWYgUkI/1RdI' + 'ahWSZ8xM8vqza3Vb6SzI/vzw4O22TvFwNGDQcwIpxf/I0Iow+U/0uA0VFH2nPdyeJw' + 'eNjEFaPkIZEHSyJ0CUtNS2umXpx4IyUN2R9Xve4OddbUpfTFPDYdcOiqPn1IkVLan/' + 't1fyEggabsk0Mdig+lK6JEd3keU1o9cOyHeiplOrmS5mNLV2Alz6Es+gvbvsMkXKvJ' + 'rZ3+f8eVvRMNUgS/UfgIgPflUvUgxhlDCmCs/brZeZMhrUbWN00URdrfRT3xdSmNUV' + '10LPryk/l9quG8Phn8MKE1cKEEGWcBkuvF0v/f9DqMh6hsXea86oA//bYZM8Nb+mut' + 'EjXSAi5AJxfryci0MGbL5jZaO8a2yfx41f84forxMReBCATDQIzSagMK9Ixln/h/U2' + 'KZarenD6rB1rAd0pQLjXa9GMdfBJdImW3LYNpDaPuV/MPQOGRa851gCTf9Ha7rZl67' + 'ekTgwlEAskZOp6NQz8ZdCl4oc7gaTGjFttBmH1TZtKtkpuvhqXv3Ige6XCzBH40+HC' + 'nuwUCqJvPlKJHd/ikm2OfQS+BsPH8HDvrQGQyHyzBzV20oRfNGPIXVOXc9AEIJAPxB' + 'QYQE2aoTR+l7N4On4x59z8qU1UCAwEAAQ=='; + + expect(rsaUtils.serializeRSAPublicKeyPKCS8(rsaUtils.deserializeRSAPublicKeyPKCS8(serializedPublicKey)), serializedPublicKey); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('Serialize RSA private keys', () { + const rsaUtils = RsaUtils(); + test('Converting key', () async { + RSAPrivateKey privateKey = (await rsaUtils.generateRSAKeyPair()).privateKey; + String base64String = rsaUtils.serializeRSAPrivateKeyPKCS1(privateKey); + RSAPrivateKey convertedKey = rsaUtils.deserializeRSAPrivateKeyPKCS1(base64String); + + expect(privateKey.modulus, convertedKey.modulus); + expect(privateKey.exponent, convertedKey.exponent); + expect(privateKey.p, convertedKey.p); + expect(privateKey.q, convertedKey.q); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('RSA signing and verifying', () { + const rsaUtils = RsaUtils(); + test('Signature is valid', () async { + var asymmetricKeyPair = await rsaUtils.generateRSAKeyPair(); + RSAPublicKey publicKey = asymmetricKeyPair.publicKey; + RSAPrivateKey privateKey = asymmetricKeyPair.privateKey; + + String message = 'I am a signature.'; + + var signature = rsaUtils.createRSASignature(privateKey, utf8.encode(message) as Uint8List); + + expect(true, rsaUtils.verifyRSASignature(publicKey, utf8.encode(message) as Uint8List, signature)); + }, timeout: const Timeout(Duration(minutes: 5))); + + test('Signature is invalid', () async { + var asymmetricKeyPair = await rsaUtils.generateRSAKeyPair(); + RSAPublicKey publicKey = asymmetricKeyPair.publicKey; + RSAPrivateKey privateKey = asymmetricKeyPair.privateKey; + + String message = 'I am a signature.'; + + var signature = rsaUtils.createRSASignature(privateKey, utf8.encode(message) as Uint8List); + + expect(false, rsaUtils.verifyRSASignature(publicKey, utf8.encode('I am not the signature you are looking for.') as Uint8List, signature)); + }, timeout: const Timeout(Duration(minutes: 5))); + }); +} diff --git a/test/unit_test/utils/utils_test.dart b/test/unit_test/utils/utils_test.dart new file mode 100644 index 000000000..b6bf4827f --- /dev/null +++ b/test/unit_test/utils/utils_test.dart @@ -0,0 +1,82 @@ +/* + privacyIDEA Authenticator + + Authors: Timo Sturm + Frank Merkel + Copyright (c) 2017-2023 NetKnights GmbH + + Licensed under the Apache License, Version 2.0 (the 'License'); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an 'AS IS' BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacyidea_authenticator/utils/identifiers.dart'; +import 'package:privacyidea_authenticator/utils/utils.dart'; + +void main() { + _testInsertCharAt(); + _testSplitPeriodically(); + _testMapStringToAlgorithm(); + _testEnumAsString(); + _testEqualsIgnoreCase(); +} + +void _testInsertCharAt() { + const String str = 'ABCD'; + + group('insertCharAt', () { + test('Insert at start', () => expect('XABCD', insertCharAt(str, 'X', 0))); + + test('Insert at end', () => expect('ABCDX', insertCharAt(str, 'X', str.length))); + + test('Insert at end', () => expect('ABXCD', insertCharAt(str, 'X', 2))); + }); +} + +void _testSplitPeriodically() { + const String str = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + group('splitPeriodically', () { + test('Split every -1', () => expect(splitPeriodically(str, -1), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + test('Split every 0', () => expect(splitPeriodically(str, 0), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')); + test('Split every 1', () => expect(splitPeriodically(str, 1), 'A B C D E F G H I J K L M N O P Q R S T U V W X Y Z')); + test('Split every 2', () => expect('AB CD EF GH IJ KL MN OP QR ST UV WX YZ', splitPeriodically(str, 2))); + test('Split every 3', () => expect('ABC DEF GHI JKL MNO PQR STU VWX YZ', splitPeriodically(str, 3))); + test('Split every 7', () => expect('ABCDEFG HIJKLMN OPQRSTU VWXYZ', splitPeriodically(str, 7))); + test('Split every 12', () => expect('ABCDEFGHIJKL MNOPQRSTUVWX YZ', splitPeriodically(str, 12))); + }); +} + +void _testMapStringToAlgorithm() { + group('mapStringToAlgorithm', () { + test('Test SHA1', () => expect(mapStringToAlgorithm('SHA1'), Algorithms.SHA1)); + test('Test SHA256', () => expect(mapStringToAlgorithm('SHA256'), Algorithms.SHA256)); + test('Test SHA512', () => expect(mapStringToAlgorithm('SHA512'), Algorithms.SHA512)); + test('Test invalid', () => expect(() => mapStringToAlgorithm('invalid'), throwsArgumentError)); + }); +} + +void _testEnumAsString() { + group('enumAsString', () { + test('Test SHA1', () => expect(enumAsString(Algorithms.SHA1), 'SHA1')); + test('Test SHA256', () => expect(enumAsString(Algorithms.SHA256), 'SHA256')); + test('Test SHA512', () => expect(enumAsString(Algorithms.SHA512), 'SHA512')); + }); +} + +void _testEqualsIgnoreCase() { + group('equalsIgnoreCase', () { + test('Test different case', () => expect(equalsIgnoreCase('ABC', 'abc'), true)); + test('Test same case', () => expect(equalsIgnoreCase('ABC', 'ABC'), true)); + test('Test not equal same case', () => expect(equalsIgnoreCase('ABC', 'AB'), false)); + test('Test not equal different case', () => expect(equalsIgnoreCase('ABC', 'ab'), false)); + }); +} diff --git a/test/utils/crypto_utils_test.dart b/test/utils/crypto_utils_test.dart deleted file mode 100644 index c362b2bda..000000000 --- a/test/utils/crypto_utils_test.dart +++ /dev/null @@ -1,439 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:pointycastle/asymmetric/api.dart'; -import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; -import 'package:test/test.dart'; - -void main() { - _testGeneratePhoneChecksum(); - _testPbkdf2(); - _testRSASigning(); -} - -void _testGeneratePhoneChecksum() { - group('generatePhoneChecksum', () { - // Test some inputs, verify with python: - // ``` - // import hashlib - // sha1 = hashlib.sha1() - // - // myList = [1,0,2,9,3,8,4,7,5,6] - // - // sha1.update(bytearray(myList)) - // - // print('[', end='') - // for i in range(sha1.digest_size): - // - // if i is sha1.digest_size - 1: - // print(sha1.digest()[i], end='') - // else: - // print(sha1.digest()[i], end=', ') - // - // print(']', end='') - // - // import base64 - // - // print('\n') - // print(base64.b32encode(sha1.digest()[:4] + bytearray(myList))) - // ``` - - test('1. SHA-1', () async => expect(await generateWrapper([0, 1, 2, 3, 4, 5, 6]), 'NXEG6EIAAEBAGBAFAY')); - - test('2. SHA-1', () async => expect(await generateWrapper([9, 8, 7, 6, 5, 4, 3, 2, 1]), 'THKHQSYJBADQMBIEAMBAC')); - test('3. SHA-1', () async => expect(await generateWrapper([3, 5, 7, 2, 3, 4, 9, 1, 0, 4, 7, 3, 5, 6]), 'TGEEJ7QDAUDQEAYEBEAQABAHAMCQM')); - test('4. SHA-1', () async => expect(await generateWrapper([9, 5, 8, 1, 7, 3]), '2DO4TDAJAUEACBYD')); - test('5. SHA-1', () async => expect(await generateWrapper([1, 0, 2, 9, 3, 8, 4, 7, 5, 6]), 'ZOOALWIBAABASAYIAQDQKBQ')); - }); -} - -/// Just a helper method to make tests shorter -Future generateWrapper(List l) async { - return generatePhoneChecksum(phonePart: Uint8List.fromList(l)); -} - -void _testPbkdf2() { - // Output matchers generated with python: - // ``` - // from hashlib import pbkdf2_hmac - // - // password = bytearray([1, 2, 3, 4, 5]) - // - // key = pbkdf2_hmac( - // hash_name = 'sha1', - // password = password.hex().encode('utf-8'), - // salt = bytearray([1, 2, 3, 4, 5, 6, 7, 8]), - // iterations = 10000, - // dklen = 55 - // ) - // - // print('[', end='') - // for i in range(len(key)): - // - // if i is len(key) - 1: - // print(key[i], end='') - // else: - // print(key[i], end=', ') - // - // print(']', end='') - // ``` - - group('pbkdf2', () { - Uint8List password = Uint8List.fromList([4, 142, 237, 243, 55, 58, 148, 100, 127, 56, 11, 99, 75, 217, 3, 59, 121, 167, 42, 164]); - Uint8List salt = Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]); - int iterations = 10000; - int keyLen = 20; - - group('Different passwords', () { - test( - 'Pwd 1', - () async => expect( - await pbkdf2( - password: Uint8List.fromList([204, 142, 237, 243, 154, 5, 48, 206, 127, 56, 11, 156, 75, 217, 116, 59, 121, 67, 152, 46]), - keyLength: keyLen, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([105, 176, 234, 116, 177, 125, 213, 148, 111, 87, 172, 184, 141, 16, 185, 208, 250, 127, 212, 64]))); - - test( - 'Pwd 2', - () async => expect( - await pbkdf2( - password: Uint8List.fromList([66, 142, 237, 243, 12, 5, 48, 206, 127, 56, 11, 99, 75, 217, 116, 59, 121, 167, 152, 4]), - keyLength: keyLen, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([11, 157, 107, 247, 204, 194, 23, 69, 211, 238, 200, 86, 38, 234, 99, 227, 247, 44, 220, 135]))); - - test( - 'Pwd 3', - () async => expect( - await pbkdf2( - password: Uint8List.fromList([222, 142, 237, 243, 55, 5, 48, 0, 127, 56, 11, 99, 75, 217, 3, 59, 121, 167, 152, 164]), - keyLength: keyLen, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([57, 88, 51, 7, 80, 51, 239, 58, 125, 6, 80, 79, 80, 62, 16, 0, 255, 245, 137, 168]))); - - test( - 'Pwd 4', - () async => expect( - await pbkdf2( - password: Uint8List.fromList([4, 142, 237, 243, 55, 58, 148, 100, 127, 56, 11, 99, 75, 217, 3, 59, 121, 167, 42, 164]), - keyLength: keyLen, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221]))); - }); - - group('Different salts', () { - test( - 'Salt 1', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: iterations, - salt: Uint8List.fromList([0, 0, 0, 0, 0, 0, 0, 0]), - ), - Uint8List.fromList([0, 149, 53, 169, 140, 36, 152, 54, 213, 123, 214, 14, 11, 199, 89, 78, 180, 108, 104, 177]))); - - test( - 'Salt 2', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: iterations, - salt: Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]), - ), - Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221]))); - test( - 'Salt 3', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: iterations, - salt: Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5]), - ), - Uint8List.fromList([29, 98, 40, 192, 122, 52, 24, 18, 189, 124, 119, 99, 251, 64, 81, 75, 149, 176, 77, 210]))); - test( - 'Salt 4', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: iterations, - salt: Uint8List.fromList([42, 42, 42, 5, 6, 7, 8, 42]), - ), - Uint8List.fromList([196, 70, 123, 140, 14, 167, 102, 50, 223, 223, 120, 158, 35, 10, 215, 202, 117, 26, 85, 46]))); - }); - - group('Different iterations', () { - test( - '100', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: 100, - salt: salt, - ), - Uint8List.fromList([126, 248, 52, 21, 94, 28, 200, 201, 165, 237, 0, 31, 10, 157, 59, 76, 63, 189, 247, 132]))); - - test( - '1000', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: 1000, - salt: salt, - ), - Uint8List.fromList([70, 150, 241, 120, 152, 55, 135, 238, 232, 88, 94, 42, 245, 251, 156, 76, 165, 128, 102, 119]))); - - test( - '10 000', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: 10000, - salt: salt, - ), - Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221]))); - - test( - '100 000', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: 100000, - salt: salt, - ), - Uint8List.fromList([60, 246, 237, 212, 183, 224, 78, 28, 204, 190, 27, 137, 164, 163, 80, 89, 21, 81, 244, 109]))); - - test( - '1 000 000', - () async => expect( - await pbkdf2( - password: password, - keyLength: keyLen, - iterations: 1000000, - salt: salt, - ), - Uint8List.fromList([25, 39, 153, 115, 182, 177, 160, 241, 96, 198, 31, 79, 145, 109, 102, 47, 205, 167, 246, 253]))); - }, timeout: const Timeout(Duration(seconds: 60))); - - group('Different output lengths', () { - test( - 'Key lenght 1', - () async => expect( - await pbkdf2( - password: password, - keyLength: 1, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([135]))); - - test( - 'Key lenght 5', - () async => expect( - await pbkdf2( - password: password, - keyLength: 5, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([135, 33, 148, 191, 86]))); - test( - 'Key lenght 12', - () async => expect( - await pbkdf2( - password: password, - keyLength: 12, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246]))); - - test( - 'Key lenght 20', - () async => expect( - await pbkdf2( - password: password, - keyLength: 20, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([135, 33, 148, 191, 86, 136, 13, 50, 14, 0, 188, 246, 48, 26, 209, 229, 68, 239, 111, 221]))); - - test( - 'Key lenght 33', - () async => expect( - await pbkdf2( - password: password, - keyLength: 33, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([ - 135, - 33, - 148, - 191, - 86, - 136, - 13, - 50, - 14, - 0, - 188, - 246, - 48, - 26, - 209, - 229, - 68, - 239, - 111, - 221, - 6, - 22, - 78, - 185, - 134, - 87, - 110, - 131, - 183, - 7, - 5, - 208, - 219 - ]))); - - test( - 'Key lenght 55', - () async => expect( - await pbkdf2( - password: password, - keyLength: 55, - iterations: iterations, - salt: salt, - ), - Uint8List.fromList([ - 135, - 33, - 148, - 191, - 86, - 136, - 13, - 50, - 14, - 0, - 188, - 246, - 48, - 26, - 209, - 229, - 68, - 239, - 111, - 221, - 6, - 22, - 78, - 185, - 134, - 87, - 110, - 131, - 183, - 7, - 5, - 208, - 219, - 82, - 16, - 35, - 40, - 99, - 223, - 134, - 45, - 102, - 101, - 59, - 19, - 20, - 47, - 119, - 212, - 164, - 58, - 255, - 137, - 22, - 83 - ]))); - }); - }); -} - -void _testRSASigning() { - group('RSA signing and verifying', () { - test('Signature is valid', () async { - var asymmetricKeyPair = await generateRSAKeyPair(); - RSAPublicKey publicKey = asymmetricKeyPair.publicKey; - RSAPrivateKey privateKey = asymmetricKeyPair.privateKey; - - String message = 'I am a signature.'; - - var signature = createRSASignature(privateKey, utf8.encode(message) as Uint8List); - - expect(true, verifyRSASignature(publicKey, utf8.encode(message) as Uint8List, signature)); - }, timeout: const Timeout(Duration(minutes: 5))); - - test('Signature is invalid', () async { - var asymmetricKeyPair = await generateRSAKeyPair(); - RSAPublicKey publicKey = asymmetricKeyPair.publicKey; - RSAPrivateKey privateKey = asymmetricKeyPair.privateKey; - - String message = 'I am a signature.'; - - var signature = createRSASignature(privateKey, utf8.encode(message) as Uint8List); - - expect(false, verifyRSASignature(publicKey, utf8.encode('I am not the signature you are looking for.') as Uint8List, signature)); - }, timeout: const Timeout(Duration(minutes: 5))); - }, timeout: const Timeout(Duration(minutes: 16))); -} diff --git a/test/utils/parsing_utils_test.dart b/test/utils/parsing_utils_test.dart deleted file mode 100644 index 2cb54f901..000000000 --- a/test/utils/parsing_utils_test.dart +++ /dev/null @@ -1,363 +0,0 @@ -import 'package:pointycastle/export.dart'; -import 'package:privacyidea_authenticator/utils/crypto_utils.dart'; -import 'package:privacyidea_authenticator/utils/identifiers.dart'; -import 'package:privacyidea_authenticator/utils/parsing_utils.dart'; -import 'package:privacyidea_authenticator/utils/utils.dart'; -import 'package:test/test.dart'; - -void main() { - _testSerializingRSAKeys(); - _testParseOtpAuth(); - _testParsingLabelAndIssuer(); -} - -void _testParsingLabelAndIssuer() { - group('Parsing Label and Issuer', () { - String uriWithIssuerParam = 'otpauth://totp/alice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'; - String uriWithIssuer = 'otpauth://totp/Example:alice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&algorithm=SHA512&digits=8&period=60'; - String uriWithIssuerAndUriEncoding = 'otpauth://totp/Example%3Aalice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&algorithm=SHA512&digits=8&period=60'; - String uriWithIssuerParamAndIssuer = 'otpauth://totp/Example:alice@google.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'; - String uriWithoutIssuerAndLabel = 'otpauth://totp/?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&algorithm=SHA512&digits=8&period=60'; - - test('Test parse issuer from param', () { - Map map = parseQRCodeToMap(uriWithoutIssuerAndLabel); - expect(map[URI_LABEL], ''); - expect(map[URI_ISSUER], ''); - }); - - test('Test parse issuer from param', () { - Map map = parseQRCodeToMap(uriWithIssuerParam); - expect(map[URI_LABEL], 'alice@google.com'); - expect(map[URI_ISSUER], 'ACME Co'); - }); - - test('Test parse issuer from label', () { - Map map = parseQRCodeToMap(uriWithIssuer); - expect(map[URI_LABEL], 'alice@google.com'); - expect(map[URI_ISSUER], 'Example'); - }); - - test('Test parse issuer from label with uri encoding', () { - Map map = parseQRCodeToMap(uriWithIssuerAndUriEncoding); - expect(map[URI_LABEL], 'alice@google.com'); - expect(map[URI_ISSUER], 'Example'); - }); - - test('Test parse issuer from param and label', () { - Map map = parseQRCodeToMap(uriWithIssuerParamAndIssuer); - expect(map[URI_LABEL], 'alice@google.com'); - expect(map[URI_ISSUER], 'Example'); - }); - }); -} - -void _testSerializingRSAKeys() { - group('PKCS#1 format', () { - test('Converting key', () async { - RSAPublicKey publicKey = RSAPublicKey(BigInt.from(431254), BigInt.from(32545)); - - String base64String = serializeRSAPublicKeyPKCS1(publicKey); - RSAPublicKey convertedKey = deserializeRSAPublicKeyPKCS1(base64String); - - expect(publicKey.modulus, convertedKey.modulus); - expect(publicKey.exponent, convertedKey.exponent); - }, timeout: const Timeout(Duration(seconds: 60))); - - test('Converting generated key', () async { - var asymmetricKeyPair = await generateRSAKeyPair(); - RSAPublicKey publicKey = asymmetricKeyPair.publicKey; - - String base64String = serializeRSAPublicKeyPKCS1(publicKey); - RSAPublicKey convertedKey = deserializeRSAPublicKeyPKCS1(base64String); - - expect(publicKey.modulus, convertedKey.modulus); - expect(publicKey.exponent, convertedKey.exponent); - }, timeout: const Timeout(Duration(seconds: 60))); - - test('Parsing existing key', () async { - String serializedPublicKey = 'MIICCgKCAgEAtOE6hDrwB+9Quk5Ibp9DduUMAmQ' - 'i3KSn4pSZPrj4vhx9COenh+K6NtWFDwSPZcEOMk/s7GXsgAzdQvUVp4KpmBSAL3C' - 'XgwZrhG4DZWRvXhB4P0Toxz1McVnPvabriWqU1L3Jorca1bnlvaaYh9rywbBrxes' - 'IA4VUmfFoWHpn+HMdYp4g2UG1UeBIqBsgI4syPiwlEDW6sWTeSDcvQWTYGBsHMXf' - 'zqNGT6ONo5mTSGqI7F75+KtJdtWfNxOKC9pKXXDG8UlgkkhWu0N6sCu/1PEsDxrc' - 'pW7sKKrrB37J8jbEIOHzg67LgCWqFQMoBmIVRHlzQb5HKIswP10AmjJ7Mks0H1db' - 'jK0/ONnU4A9QzjM0ZQt3mvCe8gE0FwQa7CYv8o1OKItQaxPhqBvcLJqjjXc8iFwJ' - 'Qx5XsFU9jMJskQo+2pBBdW7oGRNqdyX0Zx36OQ48OaqbTciNT7oVQrIPd0oIiHjD' - 'LnwBvwn3y5HmvmczdFAs2gQSryJ2/tS/zxrT/OjcGK4JQGDzbjog4fz7kox0PnGg' - 'ssLfoonhflfpM5Om3vGePeqNnISTbA/yCH7X07dZf2BT5/41/OKzNjGzShFNwifb' - 'WBf1mlwUNh1Vuu+ZGdTQKisxI4G8k2dZrlTWkQqOmLebCE3L38jnh0Oek+Jl9fNm' - 'TcMl8sPWxB8lgGpUCAwEAAQ=='; - - expect(serializeRSAPublicKeyPKCS1(deserializeRSAPublicKeyPKCS1(serializedPublicKey)), serializedPublicKey); - }, timeout: const Timeout(Duration(seconds: 60))); - }, timeout: const Timeout(Duration(seconds: 300))); - - group('PKCS#8 format', () { - test('Converting key', () async { - RSAPublicKey publicKey = RSAPublicKey(BigInt.from(431254), BigInt.from(32545)); - - String base64String = serializeRSAPublicKeyPKCS8(publicKey); - RSAPublicKey convertedKey = deserializeRSAPublicKeyPKCS8(base64String); - - expect(publicKey.modulus, convertedKey.modulus); - expect(publicKey.exponent, convertedKey.exponent); - }, timeout: const Timeout(Duration(seconds: 60))); - - test('Converting generated key', () async { - var asymmetricKeyPair = await generateRSAKeyPair(); - RSAPublicKey publicKey = asymmetricKeyPair.publicKey; - - String base64String = serializeRSAPublicKeyPKCS8(publicKey); - RSAPublicKey convertedKey = deserializeRSAPublicKeyPKCS8(base64String); - - expect(publicKey.modulus, convertedKey.modulus); - expect(publicKey.exponent, convertedKey.exponent); - }, timeout: const Timeout(Duration(seconds: 60))); - - test('Parse existing key', () async { - String serializedPublicKey = 'MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCA' - 'gEAwdxugfnlsrd3rwZsEvI8GzEF4BtGEK3+vXRWVv43Z0Itn9NAtN5TWYgUkI/1RdI' - 'ahWSZ8xM8vqza3Vb6SzI/vzw4O22TvFwNGDQcwIpxf/I0Iow+U/0uA0VFH2nPdyeJw' - 'eNjEFaPkIZEHSyJ0CUtNS2umXpx4IyUN2R9Xve4OddbUpfTFPDYdcOiqPn1IkVLan/' - 't1fyEggabsk0Mdig+lK6JEd3keU1o9cOyHeiplOrmS5mNLV2Alz6Es+gvbvsMkXKvJ' - 'rZ3+f8eVvRMNUgS/UfgIgPflUvUgxhlDCmCs/brZeZMhrUbWN00URdrfRT3xdSmNUV' - '10LPryk/l9quG8Phn8MKE1cKEEGWcBkuvF0v/f9DqMh6hsXea86oA//bYZM8Nb+mut' - 'EjXSAi5AJxfryci0MGbL5jZaO8a2yfx41f84forxMReBCATDQIzSagMK9Ixln/h/U2' - 'KZarenD6rB1rAd0pQLjXa9GMdfBJdImW3LYNpDaPuV/MPQOGRa851gCTf9Ha7rZl67' - 'ekTgwlEAskZOp6NQz8ZdCl4oc7gaTGjFttBmH1TZtKtkpuvhqXv3Ige6XCzBH40+HC' - 'nuwUCqJvPlKJHd/ikm2OfQS+BsPH8HDvrQGQyHyzBzV20oRfNGPIXVOXc9AEIJAPxB' - 'QYQE2aoTR+l7N4On4x59z8qU1UCAwEAAQ=='; - - expect(serializeRSAPublicKeyPKCS8(deserializeRSAPublicKeyPKCS8(serializedPublicKey)), serializedPublicKey); - }, timeout: const Timeout(Duration(seconds: 60))); - }, timeout: const Timeout(Duration(seconds: 300))); - - group('Serialize RSA private keys', () { - test('Converting key', () async { - RSAPrivateKey privateKey = (await generateRSAKeyPair()).privateKey; - - String base64String = serializeRSAPrivateKeyPKCS1(privateKey); - RSAPrivateKey convertedKey = deserializeRSAPrivateKeyPKCS1(base64String); - - expect(privateKey.modulus, convertedKey.modulus); - expect(privateKey.exponent, convertedKey.exponent); - expect(privateKey.p, convertedKey.p); - expect(privateKey.q, convertedKey.q); - }, timeout: const Timeout(Duration(seconds: 60))); - }, timeout: const Timeout(Duration(seconds: 300))); -} - -void _testParseOtpAuth() { - group('Parse HOTP and TOTP uri', () { - test('Test with wrong uri schema', () { - expect( - () => parseQRCodeToMap('http://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=6&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test with unknown type', () { - expect( - () => parseQRCodeToMap('otpauth://asdf/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=6&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test with missing type', () { - expect( - () => parseQRCodeToMap('otpauth:///ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=6&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test missing algorithm', () { - Map map = parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&digits=6&period=30'); - expect(map[URI_ALGORITHM], 'SHA1'); // This is the default value - }); - - test('Test unknown algorithm', () { - expect( - () => parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=BubbleSort&digits=6&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test missing digits', () { - Map map = parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&period=30'); - expect(map[URI_DIGITS], 6); // This is the default value - }); - - // At least the library used to calculate otp values does not support other number of digits. - test('Test invalid number of digits', () { - expect( - () => parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=66&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test invalid characters for digits', () { - expect( - () => parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=aA&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test missing secret', () { - expect( - () => parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30'), - throwsA(const TypeMatcher())); - }); - - test('Test invalid secret', () { - expect( - () => parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=ÖÖ&issuer=ACME%20Co&algorithm=SHA1&digits=6' - '&period=30'), - throwsA(const TypeMatcher())); - }); - - // TOTP specific - test('Test missing period', () { - Map map = parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=6'); - expect(map[URI_PERIOD], 30); - }); - - test('Test invalid characters for period', () { - expect( - () => parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=6&period=aa'), - throwsA(const TypeMatcher())); - }); - - test('Test longer values for period', () { - Map map = parseQRCodeToMap('otpauth://totp/ACME%20Co:john@example.com?' - 'secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co' - '&algorithm=SHA1&digits=6&period=124432'); - - expect(map[URI_PERIOD], 124432); - }); - - test('Test valid totp uri', () { - Map map = parseQRCodeToMap('otpauth://totp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&issuer=ACME%20Co&algorithm=SHA512&digits=8&period=60'); - expect(map[URI_LABEL], 'Kitchen'); - expect(map[URI_ALGORITHM], 'SHA512'); - expect(map[URI_DIGITS], 8); - expect(map[URI_SECRET], decodeSecretToUint8('HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ', Encodings.base32)); - expect(map[URI_PERIOD], 60); - }); - - // HOTP specific - test('Test with missing counter', () { - expect( - () => parseQRCodeToMap('otpauth://hotp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&issuer=ACME%20Co&algorithm=SHA256&digits=8'), - throwsA(const TypeMatcher())); - }); - - test('Test with invalid counter', () { - expect( - () => parseQRCodeToMap('otpauth://hotp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&issuer=ACME%20Co&algorithm=SHA256&digits=8&counter=aa'), - throwsA(const TypeMatcher())); - }); - - test('Test valid hotp uri', () { - Map map = parseQRCodeToMap('otpauth://hotp/Kitchen?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ' - '&issuer=ACME%20Co&algorithm=SHA256&digits=8&counter=5'); - expect(map[URI_LABEL], 'Kitchen'); - expect(map[URI_ALGORITHM], 'SHA256'); - expect(map[URI_DIGITS], 8); - expect(map[URI_SECRET], decodeSecretToUint8('HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ', Encodings.base32)); - expect(map[URI_COUNTER], 5); - }); - }); - - group('2 Step Rollout', () { - test('is2StepURI', () { - expect( - is2StepURI(Uri.parse( - 'otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8&2step_output=20')), - true); - expect( - is2StepURI(Uri.parse( - 'otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8&2step_difficulty=10000')), - true); - expect( - is2StepURI(Uri.parse( - 'otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_output=20&2step_difficulty=10000')), - true); - expect(is2StepURI(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA&2step_salt=8')), - true); - - expect(is2StepURI(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE&counter=1&digits=6&issuer=privacyIDEA')), false); - }); - - test('parse complete uri', () { - Map uriMap = parseOtpAuth(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE' - '&counter=1&digits=6&issuer=privacyIDEA&2step_salt=54' - '&2step_output=42&2step_difficulty=12345')); - - expect(uriMap[URI_SALT_LENGTH], 54); - expect(uriMap[URI_OUTPUT_LENGTH_IN_BYTES], 42); - expect(uriMap[URI_ITERATIONS], 12345); - }); - - test('parse with default values', () { - expect( - parseOtpAuth(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE' - '&counter=1&digits=6&issuer=privacyIDEA&2step_output=42' - '&2step_difficulty=12345'))[URI_SALT_LENGTH], - 10); - expect( - parseOtpAuth(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE' - '&counter=1&digits=6&issuer=privacyIDEA&2step_salt=54' - '&2step_difficulty=12345'))[URI_OUTPUT_LENGTH_IN_BYTES], - 20); - expect( - parseOtpAuth(Uri.parse('otpauth://hotp/OATH0001F662?secret=HDOMWJ5GEQQA6RR34RAP55QBVCX3E2RE' - '&counter=1&digits=6&issuer=privacyIDEA&2step_salt=54' - '&2step_output=42'))[URI_ITERATIONS], - 10000); - }); - }); - - group('Push Token', () { - test('parse complete uri', () { - Map uriMap = parsePiAuth(Uri.parse('otpauth://pipush/PIPU0001353C?url=https%3A//192.168.178.32/ttype/' - 'push' - '&ttl=2' - '&issuer=privacyIDEA' - '&enrollment_credential=69fe' - '&v=1' - '&serial=PIPU0001353C' - '&sslverify=0')); - - expect(uriMap[URI_LABEL], 'PIPU0001353C'); - expect(uriMap[URI_SERIAL], 'PIPU0001353C'); - expect(uriMap[URI_TTL], 2); - expect(uriMap[URI_ISSUER], 'privacyIDEA'); - expect(uriMap[URI_ENROLLMENT_CREDENTIAL], '69fe'); - expect(uriMap[URI_SSL_VERIFY], false); - }); - }); -} diff --git a/test/utils/utils_test.dart b/test/utils/utils_test.dart deleted file mode 100644 index 95154fb1e..000000000 --- a/test/utils/utils_test.dart +++ /dev/null @@ -1,243 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:privacyidea_authenticator/model/tokens/hotp_token.dart'; -import 'package:privacyidea_authenticator/utils/identifiers.dart'; -import 'package:privacyidea_authenticator/utils/utils.dart'; - -void main() { - _testDecodeSecretToUint8(); - _testcalculateOtpValue(); - _testInsertCharAt(); -} - -void _testInsertCharAt() { - const String str = 'ABCD'; - - group('insertCharAt', () { - test('Insert at start', () => expect('XABCD', insertCharAt(str, 'X', 0))); - - test('Insert at end', () => expect('ABCDX', insertCharAt(str, 'X', str.length))); - - test('Insert at end', () => expect('ABXCD', insertCharAt(str, 'X', 2))); - }); -} - -void _testcalculateOtpValue() { - group('Calculate hotp values', () { - group('different couters 6 digits', () { - // We need to use different tokens here, because simply incrementing the - // counter between all method calls leads to a race condition - HOTPToken token0 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 6, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 0); - HOTPToken token1 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 6, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 1); - HOTPToken token2 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 6, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 2); - HOTPToken token8 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 6, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 8); - - test('OTP for counter == 0', () => expect(token0.otpValue, '814628')); - - test('OTP for counter == 1', () => expect(token1.otpValue, '533881')); - - test('OTP for counter == 2', () => expect(token2.otpValue, '720111')); - - test('OTP for counter == 8', () => expect(token8.otpValue, '963685')); - }); - - group('different couters 8 digits', () { - // We need to use different tokens here, because simply incrementing the - // counter between all method calls leads to a race condition - HOTPToken token0 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 8, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 0); - HOTPToken token1 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 8, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 1); - HOTPToken token2 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 8, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 2); - HOTPToken token8 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 8, - secret: encodeSecretAs(utf8.encode('secret') as Uint8List, Encodings.base32), - counter: 8); - - test('OTP for counter == 0', () => expect(token0.otpValue, '31814628')); - - test('OTP for counter == 1', () => expect(token1.otpValue, '28533881')); - - test('OTP for counter == 2', () => expect(token2.otpValue, '31720111')); - - test('OTP for counter == 8', () => expect(token8.otpValue, '15963685')); - }); - - group('different algorithms 6 digits', () { - // We need to use different tokens here, because simply incrementing the - // counter between all method calls leads to a race condition - HOTPToken token0 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 6, - secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), - counter: 0); - HOTPToken token1 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA256, - digits: 6, - secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), - counter: 0); - HOTPToken token2 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA512, - digits: 6, - secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), - counter: 0); - - test('OTP for sha1', () => expect(token0.otpValue, '292574')); - - test('OTP for sha256', () => expect(token1.otpValue, '203782')); - - test('OTP for sha512', () => expect(token2.otpValue, '636350')); - }); - - group('different algorithms 8 digits', () { - // We need to use different tokens here, because simply incrementing the - // counter between all method calls leads to a race condition - HOTPToken token0 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA1, - digits: 8, - secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), - counter: 0); - HOTPToken token1 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA256, - digits: 8, - secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), - counter: 0); - HOTPToken token2 = HOTPToken( - id: '', - label: '', - issuer: '', - algorithm: Algorithms.SHA512, - digits: 8, - secret: encodeSecretAs(utf8.encode('Secret') as Uint8List, Encodings.base32), - counter: 0); - - test('OTP for sha1', () => expect(token0.otpValue, '25292574')); - - test('OTP for sha256', () => expect(token1.otpValue, '25203782')); - - test('OTP for sha512', () => expect(token2.otpValue, '99636350')); - }); - }); -} - -void _testDecodeSecretToUint8() { - group('decodeSecretToUint8', () { - test('Test non hex secret', () { - expect(() => decodeSecretToUint8('oo', Encodings.hex), throwsA(const TypeMatcher())); - - expect(() => decodeSecretToUint8('1Aö', Encodings.hex), throwsA(const TypeMatcher())); - }); - - test('Test hex secret', () { - expect(decodeSecretToUint8('ABCD', Encodings.hex), Uint8List.fromList([171, 205])); - - expect(decodeSecretToUint8('FF8', Encodings.hex), Uint8List.fromList([15, 248])); - }); - - test('Test non base32 secret', () { - expect(() => decodeSecretToUint8('p', Encodings.base32), throwsA(const TypeMatcher())); - - expect(() => decodeSecretToUint8('AAAAAAöA', Encodings.base32), throwsA(const TypeMatcher())); - }); - - test('Test base32 secret', () { - expect(decodeSecretToUint8('ABCD', Encodings.base32), Uint8List.fromList([0, 68])); - - expect(decodeSecretToUint8('DEG3', Encodings.base32), Uint8List.fromList([25, 13])); - }); - - test('Test utf-8 secret', () { - expect(decodeSecretToUint8('ABCD', Encodings.none), Uint8List.fromList([65, 66, 67, 68])); - - expect(decodeSecretToUint8('DEG3', Encodings.none), Uint8List.fromList([68, 69, 71, 51])); - }); - }); -} diff --git a/test_driver/integration_test_utils.dart b/test_driver/integration_test_utils.dart deleted file mode 100644 index f2cc56e27..000000000 --- a/test_driver/integration_test_utils.dart +++ /dev/null @@ -1,73 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Imports the Flutter Driver API. - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:test/test.dart'; - -void addTokenRoutine(String name, String secret) { - group('Copy otp value to clipboard', () { - FlutterDriver? driver; - - // Connect to the Flutter driver before running any tests. - setUpAll(() async { - driver = await FlutterDriver.connect(); - }); - - // Close the connection to the driver after the tests have completed. - tearDownAll(() async { - if (driver != null) { - driver!.close(); - } - }); - - test('Click the "add" button', () async { - await driver!.tap(find.byType('PopupMenuButton')); - await driver!.tap(find.text('Add token')); - }); - - test('Enter name and secret', () async { - // Enter the name. - await driver!.tap(find.ancestor(of: find.text('Name'), matching: find.byType('TextFormField'))); - - await driver!.enterText(name); - - // Enter the secret. - await driver!.tap(find.ancestor(of: find.text('Secret'), matching: find.byType('TextFormField'))); - - await driver!.enterText(secret); - }); - - test('Click "add" token', () async { - await driver!.tap(find.text('Add token')); - }); - - test('Assert the token exists', () async { - await driver!.tap(find.text(name)); - }); - }); -} - -Future doLongPress(FlutterDriver driver, SerializableFinder target) async { - // Pressing 2 seconds is needed to start the 'paste' dialog on a text field. - await driver.scroll(target, 0, 0, const Duration(seconds: 2)); - return true; -} diff --git a/test_driver/run_all.dart b/test_driver/run_all.dart deleted file mode 100644 index 1215f095c..000000000 --- a/test_driver/run_all.dart +++ /dev/null @@ -1,37 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import 'package:flutter_driver/driver_extension.dart'; -import 'package:privacyidea_authenticator/main.dart' as app; - -void main() { - // Override the supported locales of the application to prevent buttons having - // different text values. - - // FIXME Find a new way to do this? -// app.PrivacyIDEAAuthenticator.supportedLocales = [Locale('en', '')]; - - // This line enables the extension. - enableFlutterDriverExtension(); - - // Call the `main()` function of the app, or call `runApp` with - // any widget you are interested in testing. - app.main(); -} diff --git a/test_driver/run_all_test.dart b/test_driver/run_all_test.dart deleted file mode 100644 index 0b24dce86..000000000 --- a/test_driver/run_all_test.dart +++ /dev/null @@ -1,32 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Imports the Flutter Driver API. - -import 'test_components/add_token_test.dart'; -import 'test_components/copy_to_clipboard_test.dart'; -import 'test_components/rename_and_delete_test.dart'; - -void main() { - addTokenTest(); - renameAndDeleteTest(); -// totpTokenUpdateTest(); // FIXME This fails because of race-conditions! - copyToClipboardTest(); -} diff --git a/test_driver/test_components/add_token_test.dart b/test_driver/test_components/add_token_test.dart deleted file mode 100644 index 727c9cfb8..000000000 --- a/test_driver/test_components/add_token_test.dart +++ /dev/null @@ -1,91 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Imports the Flutter Driver API. - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:test/test.dart'; - -void addTokenTest() { - group('Add token manually', () { - FlutterDriver? driver; - - // Connect to the Flutter driver before running any tests. - setUpAll(() async { - driver = await FlutterDriver.connect(); - }); - - // Close the connection to the driver after the tests have completed. - tearDownAll(() async { - if (driver != null) { - driver!.close(); - } - }); - - final buttonFinder = find.byType('PopupMenuButton'); - final addTokenButton = find.text('Add token'); - - test('Click the "add" button', () async { - await driver!.tap(buttonFinder); - await driver!.tap(addTokenButton); - }); - - test('Enter name and secret', () async { - await driver!.tap(find.ancestor(of: find.text('Name'), matching: find.byType('TextFormField'))); - - await driver!.enterText('TestName'); - - await driver!.tap(find.ancestor(of: find.text('Secret'), matching: find.byType('TextFormField'))); - - await driver!.enterText('TestSecret'); - }); - - test('Change token type', () async { - await driver!.tap(find.text('SHA1')); - await driver!.tap(find.text('SHA512')); - - await driver!.tap(find.text('6')); - await driver!.tap(find.text('8')); - }); - - test('Click "add" token', () async { - await driver!.tap(find.text('Add token')); - }); - - test('Assert the token exists', () async { - await driver!.tap(find.text('TestName')); - await driver!.tap(find.text('3058 7488')); - }); - - test('Clean up', () async { - await driver!.scroll(find.text('TestName'), -500, 0, const Duration(milliseconds: 100)); - - // Delete the token. - await driver!.tap(find.text('Delete')); - - // Wait for the dialog to open. - await driver!.waitFor(find.text('Confirm deletion')); - - await driver!.tap(find.text('Delete')); - - await driver!.waitForAbsent(find.text('TestName')); - }); - }); -} diff --git a/test_driver/test_components/copy_to_clipboard_test.dart b/test_driver/test_components/copy_to_clipboard_test.dart deleted file mode 100644 index f86c4fcdc..000000000 --- a/test_driver/test_components/copy_to_clipboard_test.dart +++ /dev/null @@ -1,77 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Imports the Flutter Driver API. - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:test/test.dart'; - -import '../integration_test_utils.dart'; - -void copyToClipboardTest() { - group('Copy otp value to clipboard', () { - FlutterDriver? driver; - - // Connect to the Flutter driver before running any tests. - setUpAll(() async { - driver = await FlutterDriver.connect(); - }); - - // Close the connection to the driver after the tests have completed. - tearDownAll(() async { - if (driver != null) { - driver!.close(); - } - }); - - String tokenName = 'TokenName'; - String secret = 'TokenSecret'; - addTokenRoutine(tokenName, secret); - - test('Copy otp value', () async { - await doLongPress(driver!, find.text(tokenName)); - }); - - test('Clean up', () async { - await driver!.scroll(find.text('TokenName'), -500, 0, const Duration(milliseconds: 100)); - - // Delete the token. - await driver!.tap(find.text('Delete')); - - // Wait for the dialog to open. - await driver!.waitFor(find.text('Confirm deletion')); - - await driver!.tap(find.text('Delete')); - - await driver!.waitForAbsent(find.text('TestName')); - }); - - test('Verify value is in clipboard', () async { - await driver!.tap(find.byType('PopupMenuButton')); - await driver!.tap(find.text('Add token')); - - await doLongPress(driver!, find.ancestor(of: find.text('Name'), matching: find.byType('TextFormField'))); - - await driver!.tap(find.text('Paste')); - - await driver!.waitFor(find.text('591668')); - }); - }); -} diff --git a/test_driver/test_components/rename_and_delete_test.dart b/test_driver/test_components/rename_and_delete_test.dart deleted file mode 100644 index 2648735b9..000000000 --- a/test_driver/test_components/rename_and_delete_test.dart +++ /dev/null @@ -1,92 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Imports the Flutter Driver API. - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:test/test.dart'; - -import '../integration_test_utils.dart'; - -void renameAndDeleteTest() { - group('Rename and delete', () { - FlutterDriver? driver; - - // Connect to the Flutter driver before running any tests. - setUpAll(() async { - driver = await FlutterDriver.connect(); - }); - - // Close the connection to the driver after the tests have completed. - tearDownAll(() async { - if (driver != null) { - driver!.close(); - } - }); - - String tokenName = 'TokenName'; - String secret = 'TokenSecret'; - addTokenRoutine(tokenName, secret); - - test('Assert renaming works', () async { - await driver!.scroll(find.text(tokenName), -500, 0, const Duration(milliseconds: 100)); - - await driver!.tap(find.text('Rename')); - - // Wait for the dialog to open. - await driver!.waitFor(find.text('Rename token')); - - await driver!.enterText('NewTestName'); - await driver!.tap(find.text('Rename')); - - // Assert new name is shown. - await driver!.tap(find.text('NewTestName')); - }); - - test('Assert renaming works again', () async { - await driver!.scroll(find.text('NewTestName'), -500, 0, const Duration(milliseconds: 100)); - - await driver!.tap(find.text('Rename')); - - // Wait for the dialog to open. - await driver!.waitFor(find.text('Rename token')); - - await driver!.enterText('OldTestName'); - await driver!.tap(find.text('Rename')); - - // Assert new name is shown. - await driver!.tap(find.text('OldTestName')); - }); - - test('Assert delete works', () async { - await driver!.scroll(find.text('OldTestName'), -500, 0, const Duration(milliseconds: 100)); - - // Delete the token. - await driver!.tap(find.text('Delete')); - - // Wait for the dialog to open. - await driver!.waitFor(find.text('Confirm deletion')); - - await driver!.tap(find.text('Delete')); - - await driver!.waitForAbsent(find.text('OldTestName')); - }); - }); -} diff --git a/test_driver/test_components/totp_token_test.dart b/test_driver/test_components/totp_token_test.dart deleted file mode 100644 index 1dbd9b2d9..000000000 --- a/test_driver/test_components/totp_token_test.dart +++ /dev/null @@ -1,126 +0,0 @@ -/* - privacyIDEA Authenticator - - Authors: Timo Sturm - Frank Merkel - Copyright (c) 2017-2023 NetKnights GmbH - - Licensed under the Apache License, Version 2.0 (the 'License'); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an 'AS IS' BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -// Imports the Flutter Driver API. -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:privacyidea_authenticator/model/tokens/totp_token.dart'; -import 'package:privacyidea_authenticator/utils/identifiers.dart'; -import 'package:privacyidea_authenticator/utils/logger.dart'; -import 'package:privacyidea_authenticator/utils/utils.dart'; -import 'package:test/test.dart'; - -void totpTokenUpdateTest() { - group('TOTP token update', () { - FlutterDriver? driver; - - // Connect to the Flutter driver before running any tests. - setUpAll(() async { - driver = await FlutterDriver.connect(); - }); - - // Close the connection to the driver after the tests have completed. - tearDownAll(() async { - if (driver != null) { - driver!.close(); - } - }); - - final buttonFinder = find.byType('PopupMenuButton'); - final addTokenButton = find.text('Add token'); - - test('Click the "add" button', () async { - await driver!.tap(buttonFinder); - await driver!.tap(addTokenButton); - }); - - test('Enter name and secret', () async { - await driver!.tap(find.ancestor(of: find.text('Name'), matching: find.byType('TextFormField'))); - - await driver!.enterText('TOTPTestName'); - - await driver!.tap(find.ancestor(of: find.text('Secret'), matching: find.byType('TextFormField'))); - - await driver!.enterText('TestSecret'); - }); - - test('Change algorithm', () async { - await driver!.tap(find.text('HOTP')); - await driver!.tap(find.text('TOTP')); - }); - - test('Click "add" token', () async { - await driver!.tap(find.text('Add token')); - }); - - test('Assert otp value gets updated', () async { - // The opt value of this token is the same as the one of the added token. - TOTPToken token = TOTPToken( - label: '', - issuer: '', - id: '', - algorithm: Algorithms.SHA1, - digits: 6, - secret: encodeSecretAs(utf8.encode('TestSecret') as Uint8List, Encodings.base32), - period: 30, - ); - - // We have to run this without waiting for all animations to stop - // (the animation loops in this widget) - await driver!.runUnsynchronized(() async { - String rawValue = token.otpValue.padLeft(6, '0'); - String value = insertCharAt(rawValue, ' ', rawValue.length ~/ 2); - Logger.info('1. Value: $value'); - - await driver!.tap(find.text(value)); - }); - - await driver!.runUnsynchronized(() async { - // Wait until update is due. - await Future.delayed(const Duration(seconds: 32)); - - String rawValue = token.otpValue.padLeft(6, '0'); - String value = insertCharAt(rawValue, ' ', rawValue.length ~/ 2); - - Logger.info('2. Value: $value'); - - await driver!.waitFor(find.text(value), timeout: const Duration(seconds: 40)); - }); - }, timeout: const Timeout(Duration(seconds: 60))); - - test('Clean up', () async { - await driver!.runUnsynchronized(() async { - await driver!.scroll(find.text('TOTPTestName'), -500, 0, const Duration(milliseconds: 100)); - - // Delete the token. - await driver!.tap(find.text('Delete')); - - // Wait for the dialog to open. - await driver!.waitFor(find.text('Confirm deletion')); - - await driver!.tap(find.text('Delete')); - - await driver!.waitForAbsent(find.text('TOTPTestName')); - }); - }); - }); -} diff --git a/update_serialization.sh b/update_serialization.sh old mode 100755 new mode 100644