diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4319b96..2d833ce 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + Unit)? = null - private val selectFileCode = 0x11 - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == pickDirectoryCode) { - if(resultCode != Activity.RESULT_OK) { - result.success(null) - return - } - val pickedDirectoryUri = data?.data - if (pickedDirectoryUri == null) { - result.success(null) - return - } - Thread { - try { - result.success(onPickedDirectory(pickedDirectoryUri)) - } - catch (e: Exception) { - result.error("Failed to Copy Files", e.toString(), null) - } - }.start() - } else if (requestCode == storageRequestCode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - storagePermissionRequest?.invoke(Environment.isExternalStorageManager()) - } - storagePermissionRequest = null - } else if (requestCode == selectFileCode) { - if (resultCode != Activity.RESULT_OK) { - result.success(null) - return - } - val uri = data?.data - if (uri == null) { - result.success(null) - return - } - val contentResolver = context.contentResolver - val file = DocumentFile.fromSingleUri(context, uri) - if (file == null) { - result.success(null) - return - } - val fileName = file.name - if (fileName == null) { - result.success(null) - return - } - // copy file to cache directory - val cacheDir = context.cacheDir - val newFile = File(cacheDir, fileName) - val inputStream = contentResolver.openInputStream(uri) - if (inputStream == null) { - result.success(null) - return - } - val outputStream = FileOutputStream(newFile) - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() - // send file path to flutter - result.success(newFile.absolutePath) - } - } - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine) MethodChannel( @@ -115,12 +49,30 @@ class MainActivity : FlutterActivity() { } res.success(null) } + "getDirectoryPath" -> { - this.result = res val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) - startActivityForResult(intent, pickDirectoryCode) + val launcher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> + if (activityResult.resultCode != Activity.RESULT_OK) { + result.success(null) + } + val pickedDirectoryUri = activityResult.data?.data + if (pickedDirectoryUri == null) + result.success(null) + else + Thread { + try { + result.success(onPickedDirectory(pickedDirectoryUri)) + } catch (e: Exception) { + result.error("Failed to Copy Files", e.toString(), null) + } + }.start() + } + launcher.launch(intent) } + else -> res.notImplemented() } } @@ -137,6 +89,7 @@ class MainActivity : FlutterActivity() { events.success(2) } } + override fun onCancel(arguments: Any?) { listening = false } @@ -144,7 +97,7 @@ class MainActivity : FlutterActivity() { val storageChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/storage") storageChannel.setMethodCallHandler { _, res -> - requestStoragePermission {result -> + requestStoragePermission { result -> res.success(result) } } @@ -167,12 +120,13 @@ class MainActivity : FlutterActivity() { } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - if(listening){ + if (listening) { when (keyCode) { KeyEvent.KEYCODE_VOLUME_DOWN -> { volumeListen.down() return true } + KeyEvent.KEYCODE_VOLUME_UP -> { volumeListen.up() return true @@ -184,8 +138,8 @@ class MainActivity : FlutterActivity() { /// copy the directory to tmp directory, return copied directory private fun onPickedDirectory(uri: Uri): String { - val contentResolver = context.contentResolver - var tmp = context.cacheDir + val contentResolver = contentResolver + var tmp = cacheDir tmp = File(tmp, "getDirectoryPathTemp") tmp.mkdir() copyDirectory(contentResolver, uri, tmp) @@ -194,9 +148,9 @@ class MainActivity : FlutterActivity() { } private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) { - val src = DocumentFile.fromTreeUri(context, srcUri) ?: return + val src = DocumentFile.fromTreeUri(this, srcUri) ?: return for (file in src.listFiles()) { - if(file.isDirectory) { + if (file.isDirectory) { val newDir = File(destDir, file.name!!) newDir.mkdir() copyDirectory(resolver, file.uri, newDir) @@ -212,7 +166,7 @@ class MainActivity : FlutterActivity() { } private fun requestStoragePermission(result: (Boolean) -> Unit) { - if(Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { val readPermission = ContextCompat.checkSelfPermission( this, Manifest.permission.READ_EXTERNAL_STORAGE @@ -241,8 +195,11 @@ class MainActivity : FlutterActivity() { try { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.addCategory("android.intent.category.DEFAULT") - intent.data = Uri.parse("package:" + context.packageName) - startActivityForResult(intent, storageRequestCode) + intent.data = Uri.parse("package:$packageName") + val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> + result(Environment.isExternalStorageManager()) + } + launcher.launch(intent) } catch (e: Exception) { result(false) } @@ -258,7 +215,7 @@ class MainActivity : FlutterActivity() { grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if(requestCode == storageRequestCode) { + if (requestCode == storageRequestCode) { storagePermissionRequest?.invoke(grantResults.all { it == PackageManager.PERMISSION_GRANTED }) @@ -266,21 +223,57 @@ class MainActivity : FlutterActivity() { } } - fun openFile() { + private fun openFile() { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "*/*" - startActivityForResult(intent, selectFileCode) + val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> + if (activityResult.resultCode != Activity.RESULT_OK) { + result.success(null) + } + val uri = activityResult.data?.data + if (uri == null) { + result.success(null) + return@registerForActivityResult + } + val contentResolver = contentResolver + val file = DocumentFile.fromSingleUri(this, uri) + if (file == null) { + result.success(null) + return@registerForActivityResult + } + val fileName = file.name + if (fileName == null) { + result.success(null) + return@registerForActivityResult + } + // copy file to cache directory + val cacheDir = cacheDir + val newFile = File(cacheDir, fileName) + val inputStream = contentResolver.openInputStream(uri) + if (inputStream == null) { + result.success(null) + return@registerForActivityResult + } + val outputStream = FileOutputStream(newFile) + inputStream.copyTo(outputStream) + inputStream.close() + outputStream.close() + // send file path to flutter + result.success(newFile.absolutePath) + } + launcher.launch(intent) } } -class VolumeListen{ +class VolumeListen { var onUp = fun() {} var onDown = fun() {} - fun up(){ + fun up() { onUp() } - fun down(){ + + fun down() { onDown() } } diff --git a/assets/translation.json b/assets/translation.json index 14604bb..96579de 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -210,7 +210,8 @@ "Create Folder": "新建文件夹", "Select an image on screen": "选择屏幕上的图片", "Added @count comics to download queue.": "已添加 @count 本漫画到下载队列", - "Ignore Certificate Errors": "忽略证书错误" + "Ignore Certificate Errors": "忽略证书错误", + "Authorization Required": "需要身份验证" }, "zh_TW": { "Home": "首頁", @@ -423,6 +424,7 @@ "Create Folder": "新建文件夾", "Select an image on screen": "選擇屏幕上的圖片", "Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列", - "Ignore Certificate Errors": "忽略證書錯誤" + "Ignore Certificate Errors": "忽略證書錯誤", + "Authorization Required": "需要身份驗證" } } \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 93a0af2..be8d5d6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -51,5 +51,7 @@ LSSupportsOpeningDocumentsInPlace + NSFaceIDUsageDescription + Ensure that the operation is being performed by the user themselves. diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 1cc7ed3..8c5d347 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -120,6 +120,7 @@ class _Settings with ChangeNotifier { 'enableTurnPageByVolumeKey': true, 'enableClockAndBatteryInfoInReader': true, 'ignoreCertificateErrors': false, + 'authorizationRequired': false, }; operator [](String key) { diff --git a/lib/main.dart b/lib/main.dart index 7351392..2000267 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/log.dart'; -import 'package:venera/network/app_dio.dart'; +import 'package:venera/pages/auth_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; @@ -65,15 +65,59 @@ class MyApp extends StatefulWidget { State createState() => _MyAppState(); } -class _MyAppState extends State { +class _MyAppState extends State with WidgetsBindingObserver { @override void initState() { checkUpdates(); App.registerForceRebuild(forceRebuild); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + WidgetsBinding.instance.addObserver(this); super.initState(); } + bool isAuthPageActive = false; + + OverlayEntry? hideContentOverlay; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if(!App.isMobile) { + return; + } + if (state == AppLifecycleState.inactive && hideContentOverlay == null) { + hideContentOverlay = OverlayEntry( + builder: (context) { + return Positioned.fill( + child: Container( + width: double.infinity, + height: double.infinity, + color: App.rootContext.colorScheme.surface, + ), + ); + }, + ); + Overlay.of(App.rootContext).insert(hideContentOverlay!); + } else if (hideContentOverlay != null && + state == AppLifecycleState.resumed) { + hideContentOverlay!.remove(); + hideContentOverlay = null; + } + if (state == AppLifecycleState.hidden && + appdata.settings['authorizationRequired'] && + !isAuthPageActive) { + isAuthPageActive = true; + App.rootContext.to( + () => AuthPage( + onSuccessfulAuth: () { + App.rootContext.pop(); + isAuthPageActive = false; + }, + ), + ); + } + super.didChangeAppLifecycleState(state); + } + void forceRebuild() { void rebuild(Element el) { el.markNeedsBuild(); @@ -86,14 +130,25 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { + Widget home; + if (appdata.settings['authorizationRequired']) { + home = AuthPage( + onSuccessfulAuth: () { + App.rootContext.toReplacement(() => const MainPage()); + }, + ); + } else { + home = const MainPage(); + } return MaterialApp( - home: const MainPage(), + home: home, debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: App.mainColor, surface: Colors.white, primary: App.mainColor.shade600, + // ignore: deprecated_member_use background: Colors.white, ), fontFamily: App.isWindows ? "Microsoft YaHei" : null, @@ -105,6 +160,7 @@ class _MyAppState extends State { brightness: Brightness.dark, surface: Colors.black, primary: App.mainColor.shade400, + // ignore: deprecated_member_use background: Colors.black, ), fontFamily: App.isWindows ? "Microsoft YaHei" : null, @@ -171,12 +227,12 @@ class _MyAppState extends State { } void checkUpdates() async { - if(!appdata.settings['checkUpdateOnStart']) { + if (!appdata.settings['checkUpdateOnStart']) { return; } var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; var now = DateTime.now().millisecondsSinceEpoch; - if(now - lastCheck < 24 * 60 * 60 * 1000) { + if (now - lastCheck < 24 * 60 * 60 * 1000) { return; } appdata.implicitData['lastCheckUpdate'] = now; diff --git a/lib/pages/auth_page.dart b/lib/pages/auth_page.dart new file mode 100644 index 0000000..67b0d29 --- /dev/null +++ b/lib/pages/auth_page.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:venera/utils/translations.dart'; + +class AuthPage extends StatefulWidget { + const AuthPage({super.key, this.onSuccessfulAuth}); + + final void Function()? onSuccessfulAuth; + + @override + State createState() => _AuthPageState(); +} + +class _AuthPageState extends State { + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) { + SystemNavigator.pop(); + } + }, + child: Material( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.security, size: 36), + const SizedBox(height: 16), + Text("Authentication Required".tl), + const SizedBox(height: 16), + FilledButton( + onPressed: auth, + child: Text("Continue".tl), + ), + ], + ), + ), + ), + ); + } + + void auth() async { + var localAuth = LocalAuthentication(); + var canCheckBiometrics = await localAuth.canCheckBiometrics; + if (!canCheckBiometrics && !await localAuth.isDeviceSupported()) { + widget.onSuccessfulAuth?.call(); + return; + } + var isAuthorized = await localAuth.authenticate( + localizedReason: "Please authenticate to continue".tl, + ); + if (isAuthorized) { + widget.onSuccessfulAuth?.call(); + } + } +} diff --git a/lib/pages/settings/app.dart b/lib/pages/settings/app.dart index c491f51..b35301f 100644 --- a/lib/pages/settings/app.dart +++ b/lib/pages/settings/app.dart @@ -36,12 +36,12 @@ class _AppSettingsState extends State { if (App.isAndroid) { var channel = const MethodChannel("venera/storage"); var permission = await channel.invokeMethod(''); - if(permission != true) { + if (permission != true) { context.showMessage(message: "Permission denied".tl); return; } var path = await selectDirectory(); - if(path != null) { + if (path != null) { // check if the path is writable var testFile = File(FilePath.join(path, "test")); try { @@ -177,6 +177,29 @@ class _AppSettingsState extends State { App.forceRebuild(); }, ).toSliver(), + if (!App.isLinux) + _SwitchSetting( + title: "Authorization Required".tl, + settingKey: "authorizationRequired", + onChanged: () async { + var current = appdata.settings['authorizationRequired']; + if (current) { + final auth = LocalAuthentication(); + final bool canAuthenticateWithBiometrics = + await auth.canCheckBiometrics; + final bool canAuthenticate = canAuthenticateWithBiometrics || + await auth.isDeviceSupported(); + if (!canAuthenticate) { + context.showMessage(message: "Biometrics not supported".tl); + setState(() { + appdata.settings['authorizationRequired'] = false; + }); + appdata.saveData(); + return; + } + } + }, + ).toSliver(), ], ); } diff --git a/lib/pages/settings/setting_components.dart b/lib/pages/settings/setting_components.dart index ee14ddc..6472ca8 100644 --- a/lib/pages/settings/setting_components.dart +++ b/lib/pages/settings/setting_components.dart @@ -33,9 +33,10 @@ class _SwitchSettingState extends State<_SwitchSetting> { onChanged: (value) { setState(() { appdata.settings[widget.settingKey] = value; - appdata.saveData(); }); - widget.onChanged?.call(); + appdata.saveData().then((_) { + widget.onChanged?.call(); + }); }, ), ); diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 889a7c6..04a0215 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; +import 'package:local_auth/local_auth.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; diff --git a/pubspec.lock b/pubspec.lock index 88ac6d3..22bde61 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -356,6 +356,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398" + url: "https://pub.dev" + source: hosted + version: "2.0.23" flutter_qjs: dependency: "direct main" description: @@ -512,6 +520,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "6763aaf8965f21822624cb2fd3c03d2a8b3791037b5efb0fe4b13e110f5afc92" + url: "https://pub.dev" + source: hosted + version: "1.0.46" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" lodepng_flutter: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 89e929b..19e08db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: url: https://github.com/wgh136/webdav_client ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1 battery_plus: ^6.2.0 + local_auth: ^2.3.0 dev_dependencies: flutter_test: diff --git a/windows/build.iss b/windows/build.iss index 01d6c2b..1e4ec0d 100644 --- a/windows/build.iss +++ b/windows/build.iss @@ -54,6 +54,7 @@ Source: "{#RootPath}\build\windows\x64\runner\Release\url_launcher_windows_plugi Source: "{#RootPath}\build\windows\x64\runner\Release\battery_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\screen_retriever_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RootPath}\build\windows\x64\runner\Release\local_auth_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion