diff --git a/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt b/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt index 747183cd3b..1d5cef1ff8 100644 --- a/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt +++ b/android/app/src/main/kotlin/chat/fluffy/fluffychat/MainActivity.kt @@ -1,27 +1,64 @@ package chat.fluffy.fluffychat +import android.content.Context +import android.os.Build +import android.view.WindowManager import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine - -import android.content.Context +import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + private var appSwitcherPrivacyEnabled = false + private var appSwitcherPrivacyForeground = true override fun attachBaseContext(base: Context) { super.attachBaseContext(base) } - override fun provideFlutterEngine(context: Context): FlutterEngine? { return provideEngine(this) } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - // do nothing, because the engine was been configured in provideEngine + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + APP_SWITCHER_PRIVACY_CHANNEL + ).setMethodCallHandler { call, result -> + when (call.method) { + "setState" -> { + appSwitcherPrivacyEnabled = call.argument("enabled") ?: false + appSwitcherPrivacyForeground = call.argument("foreground") ?: true + applyAppSwitcherPrivacy() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + private fun applyAppSwitcherPrivacy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setRecentsScreenshotEnabled(!appSwitcherPrivacyEnabled) + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + return + } + + if (appSwitcherPrivacyEnabled && !appSwitcherPrivacyForeground) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } } companion object { + private const val APP_SWITCHER_PRIVACY_CHANNEL = + "chat.fluffy/app_switcher_privacy" + var engine: FlutterEngine? = null + fun provideEngine(context: Context): FlutterEngine { val eng = engine ?: FlutterEngine(context, emptyArray(), true, false) engine = eng diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index dd20725f8f..b6d5bc8499 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -45,6 +45,7 @@ enum AppSettings { sendOnEnter('chat.fluffy.send_on_enter', false), displayNavigationRail('chat.fluffy.display_navigation_rail', false), experimentalVoip('chat.fluffy.experimental_voip', false), + appSwitcherPrivacy('chat.fluffy.app_switcher_privacy', 'always'), shareKeysWith('chat.fluffy.share_keys_with_2', 'all'), noEncryptionWarningShown( 'chat.fluffy.no_encryption_warning_shown', diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index b3695fc161..6bb0e338b2 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -143,6 +143,31 @@ "placeholders": {} }, "appLockDescription": "Lock the app when not using with a pin code", + "appSwitcherPrivacy": "App switcher privacy", + "@appSwitcherPrivacy": { + "type": "String", + "placeholders": {} + }, + "appSwitcherPrivacyDescription": "Hide app content in the app switcher and recents screen", + "@appSwitcherPrivacyDescription": { + "type": "String", + "placeholders": {} + }, + "appSwitcherPrivacyOff": "Off", + "@appSwitcherPrivacyOff": { + "type": "String", + "placeholders": {} + }, + "appSwitcherPrivacyAppLock": "When app lock is enabled", + "@appSwitcherPrivacyAppLock": { + "type": "String", + "placeholders": {} + }, + "appSwitcherPrivacyAlways": "Always", + "@appSwitcherPrivacyAlways": { + "type": "String", + "placeholders": {} + }, "archive": "Archive", "@archive": { "type": "String", @@ -2785,4 +2810,4 @@ } }, "deviceIdentityKey": "Device identity key" -} \ No newline at end of file +} diff --git a/lib/pages/settings_security/settings_security.dart b/lib/pages/settings_security/settings_security.dart index ef2ea4968a..4affd8e998 100644 --- a/lib/pages/settings_security/settings_security.dart +++ b/lib/pages/settings_security/settings_security.dart @@ -5,6 +5,7 @@ import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/utils/app_switcher_privacy.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; import 'package:fluffychat/widgets/app_lock.dart'; @@ -52,6 +53,14 @@ class SettingsSecurityController extends State { } } + Future setAppSwitcherPrivacyMode(AppSwitcherPrivacyMode? mode) async { + if (mode == null) return; + await AppSettings.appSwitcherPrivacy.setAppSwitcherPrivacyMode(mode); + if (!mounted) return; + await AppLock.of(context).syncAppSwitcherPrivacy(); + setState(() {}); + } + Future deleteAccountAction() async { final l10n = L10n.of(context); final matrix = Matrix.of(context); diff --git a/lib/pages/settings_security/settings_security_view.dart b/lib/pages/settings_security/settings_security_view.dart index afdf07abc3..b22985762e 100644 --- a/lib/pages/settings_security/settings_security_view.dart +++ b/lib/pages/settings_security/settings_security_view.dart @@ -7,6 +7,7 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/utils/app_switcher_privacy.dart'; import 'package:fluffychat/utils/beautify_string_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; @@ -27,6 +28,8 @@ class SettingsSecurityView extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final client = Matrix.of(context).client; + final appSwitcherPrivacyMode = + AppSettings.appSwitcherPrivacy.appSwitcherPrivacyMode; final publicMasterKey = client.userDeviceKeys[client.userID]?.masterKey?.publicKey; @@ -77,6 +80,42 @@ class SettingsSecurityView extends StatelessWidget { subtitle: L10n.of(context).sendReadReceiptsDescription, setting: AppSettings.sendPublicReadReceipts, ), + if (PlatformInfos.isMobile) ...[ + ListTile( + title: Text(L10n.of(context).appSwitcherPrivacy), + subtitle: Text( + L10n.of(context).appSwitcherPrivacyDescription, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Material( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 2, + ), + color: theme.colorScheme.onInverseSurface, + child: DropdownButton( + isExpanded: true, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + borderRadius: BorderRadius.circular( + AppConfig.borderRadius / 2, + ), + underline: const SizedBox.shrink(), + value: appSwitcherPrivacyMode, + items: [ + for (final mode in AppSwitcherPrivacyMode.values) + DropdownMenuItem( + value: mode, + child: Text( + _appSwitcherPrivacyModeLabel(context, mode), + ), + ), + ], + onChanged: controller.setAppSwitcherPrivacyMode, + ), + ), + ), + ], ListTile( trailing: const Icon(Icons.chevron_right_outlined), title: Text(L10n.of(context).blockedUsers), @@ -197,6 +236,20 @@ class SettingsSecurityView extends StatelessWidget { ), ); } + + String _appSwitcherPrivacyModeLabel( + BuildContext context, + AppSwitcherPrivacyMode mode, + ) { + switch (mode) { + case AppSwitcherPrivacyMode.off: + return L10n.of(context).appSwitcherPrivacyOff; + case AppSwitcherPrivacyMode.appLock: + return L10n.of(context).appSwitcherPrivacyAppLock; + case AppSwitcherPrivacyMode.always: + return L10n.of(context).appSwitcherPrivacyAlways; + } + } } extension on ShareKeysWith { diff --git a/lib/utils/app_switcher_privacy.dart b/lib/utils/app_switcher_privacy.dart new file mode 100644 index 0000000000..cc69d98f49 --- /dev/null +++ b/lib/utils/app_switcher_privacy.dart @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +enum AppSwitcherPrivacyMode { + off, + appLock, + always; + + static AppSwitcherPrivacyMode fromSetting(String setting) { + for (final mode in AppSwitcherPrivacyMode.values) { + if (mode.name == setting) { + return mode; + } + } + return AppSwitcherPrivacyMode.always; + } +} + +extension AppSwitcherPrivacySetting on AppSettings { + AppSwitcherPrivacyMode get appSwitcherPrivacyMode => + AppSwitcherPrivacyMode.fromSetting(value); + + Future setAppSwitcherPrivacyMode(AppSwitcherPrivacyMode mode) => + setItem(mode.name); +} + +class AppSwitcherPrivacy { + static const _channelName = 'chat.fluffy/app_switcher_privacy'; + + @visibleForTesting + static const channel = MethodChannel(_channelName); + + @visibleForTesting + static bool? debugIsAndroid; + + @visibleForTesting + static bool? debugIsMobile; + + static bool get isAndroid => debugIsAndroid ?? PlatformInfos.isAndroid; + + static bool get isMobile => debugIsMobile ?? PlatformInfos.isMobile; + + static Future setState({ + required bool enabled, + required bool foreground, + }) async { + if (!isAndroid) return; + + await channel.invokeMethod('setState', { + 'enabled': enabled, + 'foreground': foreground, + }); + } +} diff --git a/lib/widgets/app_lock.dart b/lib/widgets/app_lock.dart index 48166fa694..6e87facbb4 100644 --- a/lib/widgets/app_lock.dart +++ b/lib/widgets/app_lock.dart @@ -3,6 +3,10 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later +import 'dart:async'; + +import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/utils/app_switcher_privacy.dart'; import 'package:fluffychat/widgets/lock_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -29,11 +33,38 @@ class AppLock extends State with WidgetsBindingObserver { String? _pincode; bool _isLocked = false; bool _paused = false; - bool get isActive => + AppLifecycleState _lifecycleState = + WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed; + + bool get _hasPincode => _pincode != null && int.tryParse(_pincode!) != null && - _pincode!.length == 4 && - !_paused; + _pincode!.length == 4; + + bool get isActive => _hasPincode && !_paused; + + bool get _appSwitcherPrivacyEnabled { + if (!AppSwitcherPrivacy.isMobile) return false; + switch (AppSettings.appSwitcherPrivacy.appSwitcherPrivacyMode) { + case AppSwitcherPrivacyMode.off: + return false; + case AppSwitcherPrivacyMode.appLock: + return _hasPincode; + case AppSwitcherPrivacyMode.always: + return true; + } + } + + bool get _isForeground => _lifecycleState == AppLifecycleState.resumed; + + bool get _showAppSwitcherPrivacyCover => + _appSwitcherPrivacyEnabled && + !AppSwitcherPrivacy.isAndroid && + { + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + }.contains(_lifecycleState); @override void initState() { @@ -41,6 +72,7 @@ class AppLock extends State with WidgetsBindingObserver { _isLocked = isActive; super.initState(); WidgetsBinding.instance.addObserver(this); + unawaited(syncAppSwitcherPrivacy()); WidgetsBinding.instance.addPostFrameCallback(_checkLoggedIn); } @@ -55,6 +87,11 @@ class AppLock extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { + setState(() { + _lifecycleState = state; + }); + unawaited(syncAppSwitcherPrivacy()); + if (isActive && state == AppLifecycleState.hidden && !_isLocked && @@ -71,6 +108,7 @@ class AppLock extends State with WidgetsBindingObserver { value: pincode, ); _pincode = pincode; + await syncAppSwitcherPrivacy(); return; } @@ -100,12 +138,48 @@ class AppLock extends State with WidgetsBindingObserver { static AppLock of(BuildContext context) => Provider.of(context, listen: false); + Future syncAppSwitcherPrivacy() => AppSwitcherPrivacy.setState( + enabled: _appSwitcherPrivacyEnabled, + foreground: _isForeground, + ); + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(AppSwitcherPrivacy.setState(enabled: false, foreground: true)); + super.dispose(); + } + @override Widget build(BuildContext context) => Provider( create: (_) => this, child: Stack( fit: StackFit.expand, - children: [widget.child, if (isLocked) const LockScreen()], + children: [ + widget.child, + if (_showAppSwitcherPrivacyCover) const _AppSwitcherPrivacyCover(), + if (isLocked) const LockScreen(), + ], ), ); } + +class _AppSwitcherPrivacyCover extends StatelessWidget { + const _AppSwitcherPrivacyCover(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ColoredBox( + key: const ValueKey('app_switcher_privacy_cover'), + color: theme.colorScheme.surface, + child: Center( + child: Icon( + Icons.lock_outline, + color: theme.colorScheme.onSurface.withAlpha(96), + size: 96, + ), + ), + ); + } +} diff --git a/test/app_switcher_privacy_test.dart b/test/app_switcher_privacy_test.dart new file mode 100644 index 0000000000..d8def9b94e --- /dev/null +++ b/test/app_switcher_privacy_test.dart @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/utils/app_switcher_privacy.dart'; +import 'package:fluffychat/widgets/app_lock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const privacyCover = ValueKey('app_switcher_privacy_cover'); + const secureStorageChannel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); + + Future resetSettings() async { + SharedPreferences.setMockInitialValues({}); + await AppSettings.init(loadWebConfigFile: false); + await AppSettings.reset(loadWebConfigFile: false); + AppSwitcherPrivacy.debugIsAndroid = false; + AppSwitcherPrivacy.debugIsMobile = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(secureStorageChannel, (_) async => null); + } + + Future pumpAppLock(WidgetTester tester, {String? pincode}) async { + await tester.pumpWidget( + MaterialApp( + home: AppLockWidget( + pincode: pincode, + clients: const [], + child: const Text('Private chat content'), + ), + ), + ); + } + + setUp(resetSettings); + + test('app switcher privacy defaults to always', () { + expect( + AppSettings.appSwitcherPrivacy.appSwitcherPrivacyMode, + AppSwitcherPrivacyMode.always, + ); + }); + + testWidgets('privacy cover follows lifecycle when always enabled', ( + tester, + ) async { + await pumpAppLock(tester); + final appLock = tester.state(find.byType(AppLockWidget)); + + expect(find.byKey(privacyCover), findsNothing); + + appLock.didChangeAppLifecycleState(AppLifecycleState.inactive); + await tester.pump(); + + expect(find.byKey(privacyCover), findsOneWidget); + + appLock.didChangeAppLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + + expect(find.byKey(privacyCover), findsNothing); + }); + + testWidgets('privacy cover is not shown when setting is off', (tester) async { + await AppSettings.appSwitcherPrivacy.setAppSwitcherPrivacyMode( + AppSwitcherPrivacyMode.off, + ); + await pumpAppLock(tester); + final appLock = tester.state(find.byType(AppLockWidget)); + + appLock.didChangeAppLifecycleState(AppLifecycleState.paused); + await tester.pump(); + + expect(find.byKey(privacyCover), findsNothing); + }); + + testWidgets('app lock mode activates when pincode is set', (tester) async { + await AppSettings.appSwitcherPrivacy.setAppSwitcherPrivacyMode( + AppSwitcherPrivacyMode.appLock, + ); + await pumpAppLock(tester); + final appLock = tester.state(find.byType(AppLockWidget)); + + await appLock.changePincode('1234'); + appLock.didChangeAppLifecycleState(AppLifecycleState.paused); + await tester.pump(); + + expect(find.byKey(privacyCover), findsOneWidget); + }); + + test('android platform channel sends privacy state', () async { + AppSwitcherPrivacy.debugIsAndroid = true; + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(AppSwitcherPrivacy.channel, (call) async { + calls.add(call); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(AppSwitcherPrivacy.channel, null), + ); + + await AppSwitcherPrivacy.setState(enabled: true, foreground: false); + + expect(calls, hasLength(1)); + expect(calls.single.method, 'setState'); + expect(calls.single.arguments, {'enabled': true, 'foreground': false}); + }); +}